diff --git a/CHANGELOG.md b/CHANGELOG.md index 8696c0e..5e8952e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +#### Flight recorder +- `[recorder]` config section and per-session JSONL recordings written by + `mcp-guard serve`: every client request, every response, every guard + decision, with a SHA-256 hash chain (`prev`/`hash`) and optional HMAC signing + via `signing_key_env`. +- Payload redaction before hashing (built-in patterns for private keys, cloud + and SaaS API keys, JWTs, bearer tokens, credentials in URLs, plus the user's + `[audit] redaction_rules`), truncation of large payloads with the hash of the + original kept. +- `mcp-guard replay`: timeline of a session (`--list`, `--denied`, `--tool`, + `--json`), with the chain status. +- `mcp-guard verify`: recompute the chain, report the first bad record, exit 1 + on tampering. Checks HMAC signatures when the key is present. +- `mcp-guard wrap` enables the recorder for wrapped servers; `doctor` reports + `RECORDER_DISABLED`. + #### Argument-level policy and shadow mode - `[[policy.rules]]`: rules on tool arguments (`when` conditions with `starts_with`, `not_in`, `glob`, `regex`, numeric and existence operators), diff --git a/CLAUDE.md b/CLAUDE.md index c76b64c..33ce5f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md - MCP Guard Project Context > **Last Updated:** 2026-10-22 -> **Project Status:** v1.0.0 shipped (gateway). Local mode (wrap/doctor/pin/serve) and argument-level policy with shadow mode landed; roadmap below is the plan from here. +> **Project Status:** v1.0.0 shipped (gateway). Local mode, argument-level policy with shadow mode, and the flight recorder landed; roadmap below is the plan from here. --- @@ -34,6 +34,7 @@ mcp-guard/ Cargo workspace, version 1.0.0, AGPL-3.0 │ │ ├── src/clients.rs MCP client config discovery, wrap/unwrap/doctor logic │ │ ├── src/pin.rs Tool lockfile: canonical JSON hashing, diff, load/save │ │ ├── src/policy.rs Policy evaluation: tool globs + argument rules -> Decision +│ │ ├── src/recorder.rs Flight recorder: hash-chained JSONL sessions, redaction, verify, timeline │ │ ├── src/mcp_server/ Stdio proxy (`serve`): handshake forwarding, policy, pinning, audit │ │ ├── src/guard_tools/ guard/health, guard/metrics, guard/version tools │ │ ├── src/server/ Axum HTTP gateway, middleware, OAuth endpoints, health, dashboard/billing routes @@ -80,6 +81,8 @@ mcp-guard unwrap --client claude-desktop mcp-guard pin --config ~/.config/mcp-guard/claude-desktop/NAME.toml [--accept] [--show] mcp-guard policy test --config X.toml --tool write_file --args '{"path": "/etc/passwd"}' mcp-guard policy suggest --config X.toml [--apply] # from audit log, after mode = "observe" +mcp-guard replay --config X.toml [--list|--denied|--tool T|--json] +mcp-guard verify --config X.toml [--session all] # exit 1 on a broken chain mcp-guard serve --config X.toml # what wrapped clients launch # Gateway mode @@ -100,7 +103,8 @@ CI runs clippy with `-D warnings` on current stable. New clippy lints on toolcha - **Shell-injection validation** in `transport` rejects commands/args with shell metacharacters. `wrap` skips such entries with a reason instead of generating a config that fails at launch. - **Policy evaluation order** (`policy::evaluate`): denied_tools, then allowed_tools, then `[[policy.rules]]` in order, first match wins, no match allows. Rules only run on `tools/call` (they need arguments); `tools/list` visibility uses the globs only. An absent argument fails every operator except `exists = false`. - **Observe mode** (`policy.mode = "observe"`): nothing blocked, `PolicyWouldDeny` audit events written, denied tools stay visible in `tools/list`. Pinning is independent of mode. `policy suggest` parses the audit JSONL (`event_type` = `tool_call` / `policy_would_deny`) and `--apply` edits the config with `toml_edit` so comments survive. -- **Config struct literals in tests** must include `policy: Default::default()`. +- **Recorder** (`recorder.rs`): one JSONL file per serve session under `recorder.dir_for(config)`. `hash = H(prev + "\n" + canonical_json(record minus hash))`, H = SHA-256 or HMAC-SHA256 when `signing_key_env` is set. Redaction (built-in patterns + audit rules) and truncation happen before hashing; `payload_sha256` is over the original. `McpServer::handle_message` records the incoming message and every outgoing one; `deny()`, observe mode and hidden tools write `decision` records. Recorder failures are logged, never fatal. +- **Config struct literals in tests** must include `policy: Default::default()` and `recorder: Default::default()`. - **`upstream.transport` defaults to stdio**; multi-server configs may omit `[upstream]` entirely. - **E2E tests set `MCP_GUARD_SKIP_LICENSE_CHECK`** (in `tests/common/mod.rs`) so pro/enterprise builds start without a key. - Rate limits are not applied in serve mode (nothing to key them on). @@ -110,13 +114,13 @@ CI runs clippy with `-D warnings` on current stable. New clippy lints on toolcha ## Roadmap (agreed direction, 2026-10-22) 1. Result inspection: rules on tool results (secret/PII redaction, size limits), extending the argument rules. Cedar was considered for the policy engine and deferred: the TOML rule format covers the known cases and stays readable in one config file. -2. Flight recorder: hash-chained, redacted per-session recording plus `mcp-guard replay`. +2. Recorder in gateway mode (per identity), retention, HTML session report. 3. Protocol shim: 2026-07-28 stateless revision, relay server-initiated requests (sampling/elicitation/roots), expose stdio servers as compliant remote servers. 4. Human-in-the-loop holds for high-risk tools (approve in terminal/Slack before a `delete_*` call proceeds). 5. Prebuilt binaries + Homebrew tap (release-tiers.yml exists; no published artifacts yet). 6. Re-tier: open core complete (all auth, all transports); charge for policy packs, recorder, fleet control plane, support. `tier.rs` gating of HTTP/SSE/JWKS is the thing to remove when this happens. -Done: local mode (wrap/doctor/pin/serve), argument-level policy rules, shadow mode + `policy suggest`. +Done: local mode (wrap/doctor/pin/serve), argument-level policy rules, shadow mode + `policy suggest`, flight recorder (`replay`/`verify`). --- @@ -136,7 +140,8 @@ Done: local mode (wrap/doctor/pin/serve), argument-level policy rules, shadow mo - 2026-01: Stripe checkout, GitHub OAuth login, landing site, license-signing workers. - 2026 (to Oct): CI hardening (Windows build, cargo-deny, security audit), link cleanup, docs pointed at in-repo files. - 2026-10-22: Strategic review. Local mode shipped: `doctor`, `wrap`, `unwrap`, `pin`, `[policy]`, tool pinning, serve-mode handshake forwarding and audit. README rewritten to match the real workspace. (PR #5) -- 2026-10-22: Argument-level `[[policy.rules]]`, `mode = "observe"`, `policy test`, `policy suggest --apply`. Fixed pre-existing OAuth callback and routing test failures. +- 2026-10-22: Argument-level `[[policy.rules]]`, `mode = "observe"`, `policy test`, `policy suggest --apply`. Fixed pre-existing OAuth callback and routing test failures. (PR #6) +- 2026-10-22: Flight recorder: `[recorder]`, hash-chained session files, redaction, `replay`, `verify`, HMAC signing. --- diff --git a/README.md b/README.md index ce7b095..70cd266 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ For the servers your own agent launches on your machine. The MCP client runs `mc | Restrict which tools the agent may call | `[policy] allowed_tools` and `denied_tools`, with globs | | Detect tool poisoning and rug pulls | Tool definitions are hashed into a lockfile on first use. Changed or new tools are hidden and rejected until you review them with `mcp-guard pin` | | Record what happened | Every tool call and every denial is written to an audit log | +| Prove what happened | The flight recorder keeps every request, response and decision in a hash-chained, redacted session file. `replay` renders it, `verify` checks it | | Keep secrets out of guard configs | Environment variables stay on the client's entry and are inherited by the upstream | | Get in and out safely | `mcp-guard wrap` backs up the client config; `mcp-guard unwrap` restores it | @@ -124,6 +125,7 @@ curl -H "Authorization: Bearer mcp_YOUR_KEY" http://localhost:3000/health - Argument-level rules (`write_file` only under the workspace, `http_request` only `GET`), tested with `mcp-guard policy test` - Shadow mode: observe first, then `mcp-guard policy suggest --apply` writes a least-privilege policy from what the agent actually used - Tool pinning with a reviewable JSON lockfile (`mcp-guard pin`) +- Flight recorder: tamper-evident, redacted per-session recording; `mcp-guard replay` shows what the agent did, `mcp-guard verify` proves nobody edited it - Per-server audit log - `doctor`, `wrap`, `unwrap` for Claude Desktop, Claude Code, Cursor, Windsurf, VS Code - `guard/health`, `guard/metrics`, `guard/version` tools exposed to the agent @@ -289,6 +291,8 @@ Local mode pin Pin, review or accept the upstream's tool definitions policy test Evaluate the policy against one tool call policy suggest Propose a least-privilege policy from the audit log + replay Show a recorded session as a timeline + verify Check a recording's hash chain serve Run as a stdio proxy (what wrapped clients launch) Gateway mode @@ -314,7 +318,7 @@ Options: The direction: stop competing on authentication, which the MCP spec and every API gateway now cover, and own the layer above it, **what an agent may do and proof of what it did**. In rough order: 1. **Result inspection.** Rules on what comes back from a server (secret and PII redaction, size limits), extending today's argument rules. -2. **Flight recorder.** Tamper-evident, redacted recording of every exchange per session, with `mcp-guard replay`. +2. **Recorder in gateway mode.** Per-identity recording for `mcp-guard run`, retention, and a self-contained HTML session report. 3. **Protocol shim.** Full support for the 2026-07-28 stateless revision, relaying server-initiated requests, and exposing old stdio servers as compliant remote servers. 4. **Human-in-the-loop holds** for high-risk tools. 5. **Prebuilt binaries and a Homebrew tap.** @@ -329,6 +333,7 @@ Issues and pull requests on any of these are welcome. | Topic | Guide | |-------|-------| | Local mode | [wrap, doctor, pin, serve](docs/local-mode.md) | +| Flight recorder | [replay, verify, record format](docs/flight-recorder.md) | | Getting Started | [Quick Start](docs/quickstart.md) | | Authentication | [Auth Guide](docs/authentication.md) | | Transports | [Stdio/HTTP/SSE](docs/transports.md) | diff --git a/crates/mcp-guard-cli/src/main.rs b/crates/mcp-guard-cli/src/main.rs index 7bf8722..9eb7d5b 100644 --- a/crates/mcp-guard-cli/src/main.rs +++ b/crates/mcp-guard-cli/src/main.rs @@ -42,6 +42,7 @@ use mcp_guard_core::{ pin::ToolLockfile, policy::{self, Decision}, rate_limit::RateLimitService, + recorder::{self, Recorder, Session, SessionInfo}, router::ServerRouter, server::{self, new_oauth_state_store, AppState}, transport::{HttpTransport, SseTransport, StdioTransport, Transport}, @@ -326,6 +327,15 @@ async fn run_cli(cli: Cli) -> anyhow::Result<()> { show, timeout, } => handle_pin(&cli.config, accept, show, timeout, cli.verbose).await, + Commands::Replay { + session, + dir, + list, + denied, + tool, + json, + } => handle_replay(&cli.config, &session, dir, list, denied, tool, json), + Commands::Verify { session, dir, json } => handle_verify(&cli.config, &session, dir, json), Commands::Policy { action } => match action { PolicyAction::Test { tool, args } => handle_policy_test(&cli.config, &tool, &args), PolicyAction::Suggest { apply, audit_log } => { @@ -917,14 +927,40 @@ async fn handle_serve(config_path: &std::path::PathBuf, verbose: bool) -> anyhow let (audit_logger, audit_handle) = AuditLogger::with_tasks(&config.audit)?; + let recorder = if config.recorder.enabled { + let dir = config.recorder.dir_for(config_path); + let info = SessionInfo { + guard_version: env!("CARGO_PKG_VERSION").to_string(), + upstream: upstream_label(&config), + config_path: Some(config_path.display().to_string()), + policy_mode: Some(format!("{:?}", config.policy.mode).to_lowercase()), + ..SessionInfo::default() + }; + match Recorder::start(&config.recorder, &dir, info, &config.audit.redaction_rules) { + Ok(rec) => { + tracing::info!(file = %rec.path().display(), signed = config.recorder.signing_key().is_some(), "Flight recorder on"); + Some(Arc::new(rec)) + } + Err(e) => { + tracing::error!(error = %e, dir = %dir.display(), "Flight recorder could not start; continuing without it"); + None + } + } + } else { + None + }; + let server_config = McpServerConfig { policy: config.policy.clone(), lock_path, upstream_label: upstream_label(&config), ..McpServerConfig::default() }; - let server = McpServer::new(server_config, upstream, metrics_handle) + let mut server = McpServer::new(server_config, upstream, metrics_handle) .with_audit(std::sync::Arc::new(audit_logger)); + if let Some(rec) = recorder { + server = server.with_recorder(rec); + } tracing::info!("Starting MCP server in stdio mode"); let result = server.run().await; @@ -1255,6 +1291,217 @@ fn handle_doctor(client_specs: &[String], json: bool, strict: bool) -> anyhow::R Ok(()) } +// ============================================================================ +// Flight recorder: replay / verify +// ============================================================================ + +fn recordings_dir( + config_path: &std::path::PathBuf, + dir: Option, +) -> anyhow::Result<(std::path::PathBuf, Option)> { + if let Some(d) = dir { + return Ok((d, None)); + } + let config = Config::from_file(config_path).map_err(|e| { + anyhow::anyhow!( + "cannot load {} ({}); pass --dir to point at a recordings directory", + config_path.display(), + e + ) + })?; + Ok((config.recorder.dir_for(config_path), Some(config))) +} + +#[allow(clippy::too_many_arguments)] +fn handle_replay( + config_path: &std::path::PathBuf, + session: &str, + dir: Option, + list: bool, + denied_only: bool, + tool: Option, + json: bool, +) -> anyhow::Result<()> { + let (dir, _) = recordings_dir(config_path, dir)?; + + if list { + let sessions = recorder::list_sessions(&dir)?; + if sessions.is_empty() { + println!("No recordings in {}", dir.display()); + return Ok(()); + } + if json { + let rows: Vec = sessions + .iter() + .filter_map(|p| Session::load(p).ok()) + .map(|s| { + let info = s.info().unwrap_or_default(); + serde_json::json!({ + "file": s.path, + "session_id": info.session_id, + "started_at": info.started_at, + "upstream": info.upstream, + "records": s.records.len(), + "ended": s.ended(), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&rows)?); + return Ok(()); + } + println!("Recordings in {}:", dir.display()); + for path in sessions { + match Session::load(&path) { + Ok(s) => { + let info = s.info().unwrap_or_default(); + let decisions = s + .records + .iter() + .filter(|r| r.kind == recorder::RecordKind::Decision) + .count(); + println!( + " {:<14} {} {:>5} records {:>3} decisions {}{}", + info.session_id, + info.started_at, + s.records.len(), + decisions, + info.upstream.unwrap_or_default(), + if s.ended() { "" } else { " (open)" } + ); + } + Err(e) => println!(" {} (unreadable: {})", path.display(), e), + } + } + return Ok(()); + } + + let Some(path) = recorder::find_session(&dir, session)? else { + anyhow::bail!( + "no session matching '{}' in {} (use --list to see sessions)", + session, + dir.display() + ); + }; + let loaded = Session::load(&path)?; + + if json { + for r in &loaded.records { + println!("{}", serde_json::to_string(r)?); + } + return Ok(()); + } + + let info = loaded.info().unwrap_or_default(); + println!("Session {} {}", info.session_id, path.display()); + if let Some(up) = &info.upstream { + println!("Upstream {}", up); + } + println!( + "Records {}{} Policy mode: {}", + loaded.records.len(), + if loaded.ended() { + "" + } else { + " (session still open or cut off)" + }, + info.policy_mode.unwrap_or_else(|| "?".into()) + ); + println!(); + + let entries: Vec = recorder::timeline(&loaded) + .into_iter() + .filter(|e| !denied_only || e.denied || e.label.starts_with("session")) + .filter(|e| match &tool { + Some(t) => e.label.ends_with(&format!(" {}", t)) || e.label.starts_with("session"), + None => true, + }) + .collect(); + println!("{}", recorder::render_timeline(&entries)); + println!(); + + let v = recorder::verify(&loaded, None); + let chain = if v.ok { + if v.key_checked { + "Chain OK".to_string() + } else { + "Chain continuity OK (signed recording; run `mcp-guard verify` with the signing key in the environment to check signatures)".to_string() + } + } else { + format!( + "CHAIN BROKEN at record {} ({})", + v.first_bad_seq + .map(|s| s.to_string()) + .unwrap_or_else(|| "?".into()), + v.problems.first().cloned().unwrap_or_default() + ) + }; + println!("{}", chain); + Ok(()) +} + +fn handle_verify( + config_path: &std::path::PathBuf, + session: &str, + dir: Option, + json: bool, +) -> anyhow::Result<()> { + let (dir, config) = recordings_dir(config_path, dir)?; + let key = config.as_ref().and_then(|c| c.recorder.signing_key()); + + let paths: Vec = if session == "all" { + recorder::list_sessions(&dir)? + } else { + match recorder::find_session(&dir, session)? { + Some(p) => vec![p], + None => anyhow::bail!( + "no session matching '{}' in {} (use `mcp-guard replay --list`)", + session, + dir.display() + ), + } + }; + if paths.is_empty() { + anyhow::bail!("no recordings in {}", dir.display()); + } + + let mut all_ok = true; + let mut continuity_only = false; + let mut results = Vec::new(); + for path in &paths { + let loaded = Session::load(path)?; + let v = recorder::verify(&loaded, key.as_deref()); + all_ok &= v.ok; + continuity_only |= v.signed && !v.key_checked; + if json { + results.push(serde_json::json!({ "file": path, "result": v })); + } else { + let status = if v.ok { + if v.key_checked { + "OK " + } else { + "OK* " + } + } else { + "BROKEN " + }; + println!("{} {} ({} records)", status, path.display(), v.records); + for p in &v.problems { + println!(" {}", p); + } + } + } + if json { + println!("{}", serde_json::to_string_pretty(&results)?); + } else if continuity_only { + println!(); + println!("* signed recording checked for continuity only; set the [recorder] signing_key_env variable to verify signatures"); + } + if !all_ok { + std::process::exit(1); + } + Ok(()) +} + // ============================================================================ // Policy: test / suggest // ============================================================================ diff --git a/crates/mcp-guard-cli/tests/local_mode_tests.rs b/crates/mcp-guard-cli/tests/local_mode_tests.rs index d17f597..57b520c 100644 --- a/crates/mcp-guard-cli/tests/local_mode_tests.rs +++ b/crates/mcp-guard-cli/tests/local_mode_tests.rs @@ -90,6 +90,11 @@ fn mock_server_command() -> (String, Vec) { } fn write_guard_config(dir: &Path, policy: &str) -> PathBuf { + write_guard_config_with(dir, policy, "") +} + +/// Like `write_guard_config`, with extra TOML appended after the [policy] section. +fn write_guard_config_with(dir: &Path, policy: &str, extra: &str) -> PathBuf { let (cmd, args) = mock_server_command(); let args_toml = args .iter() @@ -109,12 +114,15 @@ enabled = true file = "{}" [policy] +{} + {} "#, cmd.replace('\\', "\\\\"), args_toml, audit.to_string_lossy().replace('\\', "\\\\"), - policy + policy, + extra ); let path = dir.join("guard.toml"); std::fs::write(&path, config).unwrap(); @@ -398,7 +406,7 @@ fn pin_writes_lockfile_then_detects_changes_then_accepts() { struct ServeSession { child: std::process::Child, - stdin: std::process::ChildStdin, + stdin: Option, stdout: BufReader, } @@ -418,14 +426,27 @@ impl ServeSession { let stdout = BufReader::new(child.stdout.take().unwrap()); Self { child, - stdin, + stdin: Some(stdin), stdout, } } fn send(&mut self, msg: Value) { - writeln!(self.stdin, "{}", msg).unwrap(); - self.stdin.flush().unwrap(); + let stdin = self.stdin.as_mut().expect("session closed"); + writeln!(stdin, "{}", msg).unwrap(); + stdin.flush().unwrap(); + } + + /// Close stdin the way a client does on exit and wait for the guard to finish. + fn close(mut self) { + drop(self.stdin.take()); + for _ in 0..100 { + if let Ok(Some(_)) = self.child.try_wait() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + panic!("guard did not exit after stdin closed"); } /// Read until the response with this id arrives (notifications are skipped). @@ -771,3 +792,233 @@ fn observe_mode_then_policy_suggest_then_apply() { ); assert_eq!(r["error"]["code"], -32010, "{}", r); } + +// ---------------------------------------------------------------------------- +// flight recorder: replay / verify +// ---------------------------------------------------------------------------- + +fn recorder_section(dir: &Path) -> String { + format!( + "[recorder]\nenabled = true\ndir = \"{}\"\n", + dir.join("recordings") + .to_string_lossy() + .replace('\\', "\\\\") + ) +} + +#[test] +fn serve_records_session_and_replay_verify_work() { + let temp = tempfile::tempdir().unwrap(); + let config = write_guard_config_with( + temp.path(), + "pin_tools = false\ndenied_tools = [\"delete_*\"]", + &recorder_section(temp.path()), + ); + + { + let mut s = ServeSession::start(&config, &[]); + s.handshake(); + s.tool_names(2); + let r = s.request( + 3, + "tools/call", + json!({"name": "read_file", "arguments": {"path": "/work/a.md", "token": "ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"}}), + ); + assert!(r["result"].is_object(), "{}", r); + let r = s.request( + 4, + "tools/call", + json!({"name": "delete_file", "arguments": {"path": "/x"}}), + ); + assert_eq!(r["error"]["code"], -32010, "{}", r); + s.close(); + } + + let recordings = temp.path().join("recordings"); + let files: Vec<_> = std::fs::read_dir(&recordings) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(files.len(), 1, "one session file"); + let raw = std::fs::read_to_string(files[0].path()).unwrap(); + assert!( + raw.contains("session_start") && raw.contains("session_end"), + "{}", + raw + ); + assert!( + !raw.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"), + "secret redacted" + ); + assert!(raw.contains("[REDACTED:github_token]")); + + // replay --list + let out = common::cargo_bin("mcp-guard") + .args(["replay", "--config", config.to_str().unwrap(), "--list"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("records"), "{}", text); + assert!(text.contains("decisions"), "{}", text); + + // replay latest + let out = common::cargo_bin("mcp-guard") + .args(["replay", "--config", config.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("initialize"), "{}", text); + assert!(text.contains("tools/list"), "{}", text); + assert!(text.contains("tools/call read_file"), "{}", text); + assert!(text.contains("DENIED"), "{}", text); + assert!( + text.contains("decision policy_deny delete_file"), + "{}", + text + ); + assert!(text.contains("Chain OK"), "{}", text); + + // replay --denied shows only the denial and decisions + let out = common::cargo_bin("mcp-guard") + .args(["replay", "--config", config.to_str().unwrap(), "--denied"]) + .output() + .unwrap(); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("delete_file"), "{}", text); + assert!(!text.contains("tools/call read_file"), "{}", text); + + // verify passes + let out = common::cargo_bin("mcp-guard") + .args([ + "verify", + "--config", + config.to_str().unwrap(), + "--session", + "all", + ]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(String::from_utf8_lossy(&out.stdout).contains("OK")); + + // Tamper: change what the agent asked for. verify must fail and name the record. + let tampered = raw.replace("/work/a.md", "/work/b.md"); + assert_ne!(tampered, raw); + std::fs::write(files[0].path(), tampered).unwrap(); + let out = common::cargo_bin("mcp-guard") + .args(["verify", "--config", config.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + assert!( + !out.status.success(), + "tampered recording must fail verification" + ); + let report: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report[0]["result"]["ok"], false, "{}", report); + assert!( + report[0]["result"]["first_bad_seq"].is_number(), + "{}", + report + ); + + // replay also flags it + let out = common::cargo_bin("mcp-guard") + .args(["replay", "--config", config.to_str().unwrap()]) + .output() + .unwrap(); + assert!(String::from_utf8_lossy(&out.stdout).contains("CHAIN BROKEN")); +} + +#[test] +fn signed_recording_verifies_only_with_key() { + let temp = tempfile::tempdir().unwrap(); + let extra = format!( + "{}signing_key_env = \"MCP_GUARD_TEST_SIGNING_KEY\"\n", + recorder_section(temp.path()) + ); + let config = write_guard_config_with(temp.path(), "pin_tools = false", &extra); + + { + let mut s = ServeSession::start( + &config, + &[("MCP_GUARD_TEST_SIGNING_KEY", "k3y".to_string())], + ); + s.handshake(); + s.request( + 2, + "tools/call", + json!({"name": "read_file", "arguments": {}}), + ); + s.close(); + } + + // With the key: signatures verified. + let out = common::cargo_bin("mcp-guard") + .env("MCP_GUARD_TEST_SIGNING_KEY", "k3y") + .args(["verify", "--config", config.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let report: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(report[0]["result"]["signed"], true); + assert_eq!(report[0]["result"]["key_checked"], true); + + // Wrong key: fails. + let out = common::cargo_bin("mcp-guard") + .env("MCP_GUARD_TEST_SIGNING_KEY", "wrong") + .args(["verify", "--config", config.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!out.status.success()); + + // No key: continuity only, reported as such. + let out = common::cargo_bin("mcp-guard") + .env_remove("MCP_GUARD_TEST_SIGNING_KEY") + .args(["verify", "--config", config.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("OK*"), "{}", text); + assert!(text.contains("continuity only"), "{}", text); +} + +#[test] +fn replay_without_recordings_is_clear() { + let temp = tempfile::tempdir().unwrap(); + let config = write_guard_config(temp.path(), "pin_tools = false"); + let out = common::cargo_bin("mcp-guard") + .args(["replay", "--config", config.to_str().unwrap(), "--list"]) + .output() + .unwrap(); + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stdout).contains("No recordings")); + let out = common::cargo_bin("mcp-guard") + .args(["replay", "--config", config.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("no session matching")); +} diff --git a/crates/mcp-guard-core/src/cli/mod.rs b/crates/mcp-guard-core/src/cli/mod.rs index 36d3014..0564812 100644 --- a/crates/mcp-guard-core/src/cli/mod.rs +++ b/crates/mcp-guard-core/src/cli/mod.rs @@ -208,6 +208,50 @@ pub enum Commands { strict: bool, }, + /// Show a recorded session as a timeline (flight recorder) + /// + /// Sessions are written by `mcp-guard serve` when `[recorder]` is enabled. + Replay { + /// Session to show: "latest" (default), a session id, a file name, or a path + #[arg(long, default_value = "latest")] + session: String, + + /// Recordings directory (default: `[recorder]` dir from the config) + #[arg(long)] + dir: Option, + + /// List sessions instead of showing one + #[arg(long)] + list: bool, + + /// Only show denied calls and guard decisions + #[arg(long)] + denied: bool, + + /// Only show calls to this tool + #[arg(long)] + tool: Option, + + /// Print the raw records as JSON lines instead of a timeline + #[arg(long)] + json: bool, + }, + + /// Check a recorded session's hash chain for edits, gaps or reordering + Verify { + /// Session to check: "latest" (default), "all", a session id, a file name, or a path + #[arg(long, default_value = "latest")] + session: String, + + /// Recordings directory (default: `[recorder]` dir from the config) + #[arg(long)] + dir: Option, + + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Test a policy against a call, or generate one from what the agent actually used Policy { #[command(subcommand)] diff --git a/crates/mcp-guard-core/src/clients.rs b/crates/mcp-guard-core/src/clients.rs index 37d1999..54a8039 100644 --- a/crates/mcp-guard-core/src/clients.rs +++ b/crates/mcp-guard-core/src/clients.rs @@ -511,6 +511,7 @@ pub fn render_guard_config( command: &str, args: &[String], audit_log: &Path, + recordings_dir: &Path, opts: &WrapOptions, ) -> String { let mut upstream = toml::Table::new(); @@ -555,17 +556,26 @@ pub fn render_guard_config( policy.insert("pin_tools".into(), toml::Value::Boolean(opts.pin_tools)); policy.insert("on_pin_mismatch".into(), toml::Value::String("deny".into())); + let mut recorder = toml::Table::new(); + recorder.insert("enabled".into(), toml::Value::Boolean(true)); + recorder.insert( + "dir".into(), + toml::Value::String(recordings_dir.to_string_lossy().into_owned()), + ); + let mut root = toml::Table::new(); root.insert("upstream".into(), toml::Value::Table(upstream)); root.insert("audit".into(), toml::Value::Table(audit)); root.insert("policy".into(), toml::Value::Table(policy)); + root.insert("recorder".into(), toml::Value::Table(recorder)); let body = toml::to_string_pretty(&root).unwrap_or_default(); format!( "# Generated by `mcp-guard wrap` for {} server \"{}\".\n\ # mcp-guard runs this server as a subprocess and sits between it and the agent.\n\ # Edit [policy] to restrict tools; run `mcp-guard pin --config ` after\n\ - # reviewing a tool change. See docs/local-mode.md.\n\n{}", + # reviewing a tool change. `mcp-guard replay --config ` shows what the\n\ + # agent did. See docs/local-mode.md and docs/flight-recorder.md.\n\n{}", client.display_name(), server_name, body @@ -623,12 +633,18 @@ pub fn plan_wrap(loaded: &LoadedClientConfig, opts: &WrapOptions) -> Vec DoctorReport { )); } } + if !config.recorder.enabled { + report.findings.push(finding( + Severity::Info, + "RECORDER_DISABLED", + "flight recorder is off; there will be no replayable record of what the agent did".into(), + Some(format!("set [recorder] enabled = true in {}", config_path.display())), + )); + } if !config.audit.enabled { report.findings.push(finding( Severity::Warn, @@ -1254,6 +1278,16 @@ mod tests { assert_eq!(new_entry["args"][0], "--config"); assert_eq!(new_entry["args"][2], "serve"); assert_eq!(new_entry["env"]["GITHUB_TOKEN"], "ghp_secret"); + assert!( + guard_config_toml.contains("[recorder]"), + "{}", + guard_config_toml + ); + assert!( + guard_config_toml.contains("claude-desktop-filesystem"), + "{}", + guard_config_toml + ); } other => panic!("expected wrap, got {:?}", other), } diff --git a/crates/mcp-guard-core/src/config/mod.rs b/crates/mcp-guard-core/src/config/mod.rs index c56b03b..cb48dea 100644 --- a/crates/mcp-guard-core/src/config/mod.rs +++ b/crates/mcp-guard-core/src/config/mod.rs @@ -82,6 +82,10 @@ pub struct Config { #[serde(default)] pub policy: PolicyConfig, + /// Flight recorder: tamper-evident per-session recording (serve mode) + #[serde(default)] + pub recorder: RecorderConfig, + /// Database URL for persistent storage pub database_url: Option, @@ -940,6 +944,102 @@ pub fn glob_match(pattern: &str, name: &str) -> bool { false } +// ============================================================================ +// Flight Recorder Configuration (stdio proxy mode) +// ============================================================================ + +/// Flight recorder settings. See `docs/flight-recorder.md`. +/// +/// Every message the agent sends through the proxy, every response it gets +/// back, and every guard decision is appended to a per-session JSONL file. +/// Each record carries the hash of the previous one, so edits and deletions +/// are detectable with `mcp-guard verify`, and `mcp-guard replay` renders a +/// session as a timeline. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecorderConfig { + /// Record sessions. Off by default; `mcp-guard wrap` turns it on. + #[serde(default)] + pub enabled: bool, + + /// Directory for session files. Default: `.recordings/` + /// next to the config file. + #[serde(default)] + pub dir: Option, + + /// Payloads larger than this (serialized JSON bytes) are stored as a + /// preview plus the hash of the full payload. + #[serde(default = "default_max_payload_bytes")] + pub max_payload_bytes: usize, + + /// Record tool results and other responses, not only requests and decisions. + #[serde(default = "default_true")] + pub include_results: bool, + + /// Apply the built-in secret patterns and `[audit] redaction_rules` to + /// payloads before they are written. + #[serde(default = "default_true")] + pub redact: bool, + + /// Environment variable holding an HMAC key. When set, record hashes are + /// HMAC-SHA256 under that key instead of plain SHA-256, so a recording + /// cannot be forged without the key. Keep the key away from the machine + /// that writes the recordings if you need that guarantee. + #[serde(default)] + pub signing_key_env: Option, +} + +fn default_max_payload_bytes() -> usize { + 64 * 1024 +} + +impl Default for RecorderConfig { + fn default() -> Self { + Self { + enabled: false, + dir: None, + max_payload_bytes: default_max_payload_bytes(), + include_results: true, + redact: true, + signing_key_env: None, + } + } +} + +impl RecorderConfig { + /// Resolve the recordings directory for a given config file path. + pub fn dir_for(&self, config_path: &std::path::Path) -> PathBuf { + if let Some(explicit) = &self.dir { + if explicit.is_absolute() { + return explicit.clone(); + } + return match config_path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.join(explicit), + _ => explicit.clone(), + }; + } + let stem = config_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("mcp-guard"); + let dir = format!("{}.recordings", stem); + match config_path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.join(dir), + _ => PathBuf::from(dir), + } + } + + /// The signing key from the environment, if configured and present. + pub fn signing_key(&self) -> Option> { + let var = self.signing_key_env.as_ref()?; + let value = std::env::var(var).ok()?; + if value.is_empty() { + None + } else { + Some(value.into_bytes()) + } + } +} + // ============================================================================ // Upstream Configuration // ============================================================================ @@ -1442,6 +1542,7 @@ mod tests { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, } @@ -1464,6 +1565,7 @@ mod tests { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, } diff --git a/crates/mcp-guard-core/src/lib.rs b/crates/mcp-guard-core/src/lib.rs index 34b8927..6c3bd6b 100644 --- a/crates/mcp-guard-core/src/lib.rs +++ b/crates/mcp-guard-core/src/lib.rs @@ -33,6 +33,7 @@ pub mod observability; pub mod pin; pub mod policy; pub mod rate_limit; +pub mod recorder; pub mod router; pub mod server; pub mod tier; diff --git a/crates/mcp-guard-core/src/mcp_server/mod.rs b/crates/mcp-guard-core/src/mcp_server/mod.rs index 310f01b..dbd12dc 100644 --- a/crates/mcp-guard-core/src/mcp_server/mod.rs +++ b/crates/mcp-guard-core/src/mcp_server/mod.rs @@ -40,6 +40,7 @@ use crate::guard_tools::{ }; use crate::pin::{PinDiff, PinStatus, ToolLockfile}; use crate::policy::{self, Decision}; +use crate::recorder::Recorder; use crate::transport::{Message, Transport, MAX_MESSAGE_SIZE}; use metrics_exporter_prometheus::PrometheusHandle; use serde::{Deserialize, Serialize}; @@ -106,6 +107,7 @@ pub struct McpServer { guard_tools: Arc, upstream: Option>, audit: Option>, + recorder: Option>, /// Result of the most recent pin evaluation (None until the first tools/list). pin_state: tokio::sync::Mutex>, next_upstream_id: AtomicI64, @@ -170,6 +172,7 @@ impl McpServer { guard_tools, upstream, audit: None, + recorder: None, pin_state: tokio::sync::Mutex::new(None), next_upstream_id: AtomicI64::new(1_000_000), } @@ -181,6 +184,25 @@ impl McpServer { self } + /// Attach a flight recorder. Every client exchange and guard decision is recorded. + pub fn with_recorder(mut self, recorder: Arc) -> Self { + self.recorder = Some(recorder); + self + } + + /// The attached recorder, if any. + pub fn recorder(&self) -> Option<&Arc> { + self.recorder.as_ref() + } + + fn record_decision(&self, decision: &str, tool: Option<&str>, detail: Value) { + if let Some(rec) = &self.recorder { + if let Err(e) = rec.record_decision(decision, tool, detail) { + warn!(error = %e, "Flight recorder write failed"); + } + } + } + /// Whether pinning is active. pub fn pinning_enabled(&self) -> bool { self.config.lock_path.is_some() && self.config.policy.pin_tools && self.upstream.is_some() @@ -237,6 +259,11 @@ impl McpServer { if let Some(upstream) = &self.upstream { let _ = upstream.close().await; } + if let Some(rec) = &self.recorder { + if let Err(e) = rec.finish("stdin closed") { + warn!(error = %e, "Flight recorder could not write session end"); + } + } Ok(()) } @@ -251,6 +278,35 @@ impl McpServer { /// Handle one incoming message. Returns everything to write back to the /// client: forwarded upstream notifications first, then the response, if any. pub async fn handle_message(&self, message: Message) -> Vec { + if let Some(rec) = &self.recorder { + if let Err(e) = rec.record_client_message(&message) { + warn!(error = %e, "Flight recorder write failed"); + } + } + let method = message.method.clone(); + let tool = if method.as_deref() == Some("tools/call") { + message + .params + .as_ref() + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + .map(String::from) + } else { + None + }; + let outgoing = self.handle_message_inner(message).await; + if let Some(rec) = &self.recorder { + for msg in &outgoing { + if let Err(e) = rec.record_outgoing_message(msg, method.as_deref(), tool.as_deref()) + { + warn!(error = %e, "Flight recorder write failed"); + } + } + } + outgoing + } + + async fn handle_message_inner(&self, message: Message) -> Vec { // Responses from the client (to a server-initiated request) go upstream. if message.is_response() { if let Some(upstream) = &self.upstream { @@ -693,6 +749,17 @@ impl McpServer { hidden = ?hidden.iter().map(|(n, d)| format!("{} ({})", n, Self::denial_reason(d))).collect::>(), "Hiding tools from tools/list" ); + for (name, d) in &hidden { + let kind = match d { + Denial::Policy(_) => "tool_hidden_policy", + _ => "tool_hidden_pin", + }; + self.record_decision( + kind, + Some(name), + json!({ "reason": Self::denial_reason(d) }), + ); + } } result.insert("tools".to_string(), Value::Array(tools)); @@ -733,6 +800,11 @@ impl McpServer { if let Some(audit) = &self.audit { audit.log_policy_would_deny(LOCAL_IDENTITY, &tool_name, &reason); } + self.record_decision( + "policy_would_deny", + Some(&tool_name), + json!({ "reason": reason, "rule": rule }), + ); } } } @@ -786,6 +858,15 @@ impl McpServer { if let Some(audit) = &self.audit { audit.log_authz_denied(LOCAL_IDENTITY, tool, Self::denial_reason(&denial)); } + let kind = match &denial { + Denial::Policy(_) => "policy_deny", + Denial::PinMismatch(_) | Denial::NotAdvertised => "pin_deny", + }; + self.record_decision( + kind, + Some(tool), + json!({ "reason": Self::denial_reason(&denial) }), + ); Err(self.denial_error(tool, &denial)) } @@ -1488,6 +1569,71 @@ path = { not_starts_with = "/work/" } ); } + #[tokio::test] + async fn test_recorder_captures_calls_and_decisions() { + use crate::recorder::{self, RecordKind, Session, SessionInfo}; + let dir = tempfile::tempdir().unwrap(); + let rec_cfg = crate::config::RecorderConfig { + enabled: true, + ..Default::default() + }; + let recorder = Arc::new( + recorder::Recorder::start(&rec_cfg, dir.path(), SessionInfo::default(), &[]).unwrap(), + ); + + let mock = MockTransport::new(); + mock.push_response(upstream_response( + nth_upstream_id(0), + json!({"content": [{"type": "text", "text": "hi"}]}), + )); + let config = McpServerConfig { + policy: PolicyConfig { + denied_tools: vec!["delete_*".into()], + pin_tools: false, + ..PolicyConfig::default() + }, + ..McpServerConfig::default() + }; + let server = + McpServer::new(config, Some(Arc::new(mock)), None).with_recorder(recorder.clone()); + + server + .handle_message(request( + 1, + "tools/call", + Some(json!({"name": "read_file", "arguments": {"path": "/a"}})), + )) + .await; + server + .handle_message(request( + 2, + "tools/call", + Some(json!({"name": "delete_file", "arguments": {"path": "/b"}})), + )) + .await; + recorder.finish("test").unwrap(); + + let session = Session::load(recorder.path()).unwrap(); + let v = recorder::verify(&session, None); + assert!(v.ok, "{:?}", v.problems); + let kinds: Vec<(RecordKind, Option)> = session + .records + .iter() + .map(|r| (r.kind, r.tool.clone())) + .collect(); + assert_eq!(kinds[0].0, RecordKind::SessionStart); + assert_eq!(kinds[1], (RecordKind::Request, Some("read_file".into()))); + assert_eq!(kinds[2], (RecordKind::Response, Some("read_file".into()))); + assert_eq!(kinds[3], (RecordKind::Request, Some("delete_file".into()))); + assert_eq!(kinds[4], (RecordKind::Decision, Some("delete_file".into()))); + assert_eq!(kinds[5], (RecordKind::Response, Some("delete_file".into()))); + assert_eq!(kinds[6].0, RecordKind::SessionEnd); + + let text = recorder::render_timeline(&recorder::timeline(&session)); + assert!(text.contains("tools/call read_file"), "{}", text); + assert!(text.contains("DENIED"), "{}", text); + } + #[tokio::test] async fn test_fetch_upstream_tools_performs_handshake() { let mock = MockTransport::new(); diff --git a/crates/mcp-guard-core/src/recorder.rs b/crates/mcp-guard-core/src/recorder.rs new file mode 100644 index 0000000..0614b0f --- /dev/null +++ b/crates/mcp-guard-core/src/recorder.rs @@ -0,0 +1,1168 @@ +// Copyright (c) 2025 Austin Green +// SPDX-License-Identifier: AGPL-3.0 +// +// This file is part of MCP-Guard. +// +// MCP-Guard is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// MCP-Guard is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with MCP-Guard. If not, see . + +//! Flight recorder: tamper-evident, redacted, per-session recording. +//! +//! One JSONL file per session. Every record carries `prev`, the hash of the +//! record before it, and `hash`, the hash of itself (computed over the record +//! with `hash` removed, prefixed by `prev`). Removing, reordering or editing a +//! record breaks the chain at that point, which `verify` reports. With a +//! signing key the hash is an HMAC, so the chain cannot be recomputed by +//! someone who edits the file but lacks the key. +//! +//! Payloads are redacted (built-in secret patterns plus the user's audit +//! redaction rules) and truncated to a configurable size before hashing, so a +//! recording can be shared without leaking credentials. `payload_sha256` is +//! the hash of the original, so a payload you still hold can be matched to it. + +use crate::audit::CompiledRedactionRules; +use crate::config::{RecorderConfig, RedactionRule}; +use crate::pin::canonical_json; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +/// Format version written into the session header. +pub const RECORD_FORMAT_VERSION: u32 = 1; + +/// `prev` of the first record. +pub const GENESIS: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + +/// Which way a message was travelling. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + /// From the agent (client) into the guard. + ClientToGuard, + /// From the guard back to the agent. + GuardToClient, + /// A guard-internal event: session boundary or decision. + Guard, +} + +/// What a record describes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecordKind { + SessionStart, + SessionEnd, + Request, + Response, + Notification, + /// A policy or pin decision that changed what the agent saw or could do. + Decision, +} + +/// One line of a session file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Record { + pub seq: u64, + /// RFC 3339 with milliseconds, UTC. + pub ts: String, + pub dir: Direction, + pub kind: RecordKind, + /// JSON-RPC id of the request or response, when there is one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub method: Option, + /// Tool name for tools/call requests, responses and decisions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool: Option, + /// Redacted, possibly truncated payload (params, result, error, or decision detail). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload: Option, + /// SHA-256 of the full, unredacted payload as serialized. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload_sha256: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub truncated: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub redacted: bool, + /// Hash of the previous record (`GENESIS` for the first). + pub prev: String, + /// Hash of this record: `sha256:` or `hmac-sha256:` prefix. + pub hash: String, +} + +/// Session-level facts, stored in the `session_start` payload. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct SessionInfo { + pub session_id: String, + pub started_at: String, + pub guard_version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_mode: Option, + #[serde(default)] + pub signed: bool, +} + +// ---------------------------------------------------------------------------- +// Hashing +// ---------------------------------------------------------------------------- + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +/// SHA-256 of a payload's canonical JSON, as `sha256:`. +pub fn payload_hash(payload: &Value) -> String { + let digest = Sha256::digest(canonical_json(payload).as_bytes()); + format!("sha256:{}", hex(&digest)) +} + +/// Hash of a record given its predecessor's hash. The record's own `hash` +/// field is excluded from the input. +pub fn record_hash(record: &Record, key: Option<&[u8]>) -> String { + let mut without_hash = serde_json::to_value(record).unwrap_or(Value::Null); + if let Some(obj) = without_hash.as_object_mut() { + obj.remove("hash"); + } + let body = canonical_json(&without_hash); + match key { + Some(k) => { + let mut mac = Hmac::::new_from_slice(k).expect("HMAC accepts any key length"); + mac.update(record.prev.as_bytes()); + mac.update(b"\n"); + mac.update(body.as_bytes()); + format!("hmac-sha256:{}", hex(&mac.finalize().into_bytes())) + } + None => { + let mut hasher = Sha256::new(); + hasher.update(record.prev.as_bytes()); + hasher.update(b"\n"); + hasher.update(body.as_bytes()); + format!("sha256:{}", hex(&hasher.finalize())) + } + } +} + +// ---------------------------------------------------------------------------- +// Redaction and truncation +// ---------------------------------------------------------------------------- + +/// Secret shapes redacted whether or not the user configured rules. +pub fn builtin_redaction_rules() -> Vec { + let rule = |name: &str, pattern: &str| RedactionRule { + name: name.to_string(), + pattern: pattern.to_string(), + replacement: format!("[REDACTED:{}]", name), + }; + vec![ + rule( + "private_key", + r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----", + ), + rule("aws_access_key", r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), + rule("github_token", r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), + rule("github_pat", r"\bgithub_pat_[A-Za-z0-9_]{60,}\b"), + rule("slack_token", r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"), + rule("openai_key", r"\bsk-[A-Za-z0-9_-]{20,}\b"), + rule("anthropic_key", r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"), + rule("google_api_key", r"\bAIza[0-9A-Za-z_-]{35}\b"), + rule("stripe_key", r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b"), + rule("mcp_guard_key", r"\bmcp_[A-Za-z0-9_-]{30,}\b"), + rule( + "jwt", + r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b", + ), + rule("bearer", r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{16,}"), + rule("basic_auth_url", r"://[^/\s:@]+:[^/\s@]+@"), + ] +} + +/// Redactor combining built-in patterns with the user's rules. +#[derive(Clone)] +pub struct Redactor { + rules: CompiledRedactionRules, +} + +impl Redactor { + pub fn new(user_rules: &[RedactionRule]) -> Result { + let mut all = builtin_redaction_rules(); + all.extend(user_rules.iter().cloned()); + Ok(Self { + rules: CompiledRedactionRules::new(&all)?, + }) + } + + /// Redact every string inside a JSON value. Returns the value and whether + /// anything changed. + pub fn redact_value(&self, value: &Value) -> (Value, bool) { + let mut changed = false; + let out = self.walk(value, &mut changed); + (out, changed) + } + + fn walk(&self, value: &Value, changed: &mut bool) -> Value { + match value { + Value::String(s) => { + let r = self.rules.redact(s); + if &r != s { + *changed = true; + } + Value::String(r) + } + Value::Array(items) => { + Value::Array(items.iter().map(|v| self.walk(v, changed)).collect()) + } + Value::Object(map) => Value::Object( + map.iter() + .map(|(k, v)| (k.clone(), self.walk(v, changed))) + .collect(), + ), + other => other.clone(), + } + } +} + +/// Replace an oversized payload with a preview. Returns (payload, truncated). +pub fn truncate_payload(payload: Value, max_bytes: usize) -> (Value, bool) { + let serialized = payload.to_string(); + if serialized.len() <= max_bytes { + return (payload, false); + } + let preview_len = max_bytes.min(1024); + let mut end = preview_len; + while end > 0 && !serialized.is_char_boundary(end) { + end -= 1; + } + ( + json!({ + "_truncated": true, + "original_bytes": serialized.len(), + "preview": &serialized[..end] + }), + true, + ) +} + +// ---------------------------------------------------------------------------- +// Writer +// ---------------------------------------------------------------------------- + +struct WriterState { + file: std::io::BufWriter, + seq: u64, + prev: String, +} + +/// Appends records for one session to a file. +pub struct Recorder { + path: PathBuf, + session_id: String, + max_payload_bytes: usize, + include_results: bool, + redactor: Option, + key: Option>, + state: Mutex, +} + +/// File name for a new session: sortable timestamp plus a short random id. +pub fn session_file_name(started: &chrono::DateTime, session_id: &str) -> String { + format!("{}-{}.jsonl", started.format("%Y%m%d-%H%M%S"), session_id) +} + +impl Recorder { + /// Open a new session file in `dir` and write the `session_start` record. + pub fn start( + config: &RecorderConfig, + dir: &Path, + mut info: SessionInfo, + user_redaction_rules: &[RedactionRule], + ) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let started = chrono::Utc::now(); + let session_id = uuid::Uuid::new_v4().simple().to_string()[..12].to_string(); + let path = dir.join(session_file_name(&started, &session_id)); + let file = std::fs::OpenOptions::new() + .create_new(true) + .append(true) + .open(&path)?; + + let key = config.signing_key(); + let redactor = if config.redact { + Some(Redactor::new(user_redaction_rules).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()) + })?) + } else { + None + }; + + info.session_id = session_id.clone(); + info.started_at = started.to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + info.signed = key.is_some(); + + let recorder = Self { + path, + session_id, + max_payload_bytes: config.max_payload_bytes, + include_results: config.include_results, + redactor, + key, + state: Mutex::new(WriterState { + file: std::io::BufWriter::new(file), + seq: 0, + prev: GENESIS.to_string(), + }), + }; + let payload = serde_json::to_value(&info).unwrap_or(Value::Null); + recorder.append( + Direction::Guard, + RecordKind::SessionStart, + None, + None, + None, + Some(payload), + )?; + Ok(recorder) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// Record a message arriving from the client. + pub fn record_client_message( + &self, + message: &crate::transport::Message, + ) -> std::io::Result<()> { + let kind = if message.is_response() { + RecordKind::Response + } else if message.is_notification() { + RecordKind::Notification + } else { + RecordKind::Request + }; + if kind == RecordKind::Response && !self.include_results { + return Ok(()); + } + let tool = tool_of(message); + let payload = message_payload(message); + self.append( + Direction::ClientToGuard, + kind, + message.id.clone(), + message.method.clone(), + tool, + payload, + ) + } + + /// Record a message the guard is about to send to the client. `method` and + /// `tool` describe the request this answers, when known. + pub fn record_outgoing_message( + &self, + message: &crate::transport::Message, + method: Option<&str>, + tool: Option<&str>, + ) -> std::io::Result<()> { + let kind = if message.is_response() { + RecordKind::Response + } else if message.is_notification() { + RecordKind::Notification + } else { + RecordKind::Request + }; + if kind == RecordKind::Response && !self.include_results { + // Still record errors: a denial is evidence even when results are skipped. + if message.error.is_none() { + return Ok(()); + } + } + let payload = message_payload(message); + self.append( + Direction::GuardToClient, + kind, + message.id.clone(), + message.method.clone().or_else(|| method.map(String::from)), + tool.map(String::from).or_else(|| tool_of(message)), + payload, + ) + } + + /// Record a guard decision (policy denial, observe-mode would-deny, hidden tool). + pub fn record_decision( + &self, + decision: &str, + tool: Option<&str>, + detail: Value, + ) -> std::io::Result<()> { + self.append( + Direction::Guard, + RecordKind::Decision, + None, + Some(decision.to_string()), + tool.map(String::from), + Some(detail), + ) + } + + /// Write the `session_end` record and flush. + pub fn finish(&self, reason: &str) -> std::io::Result<()> { + self.append( + Direction::Guard, + RecordKind::SessionEnd, + None, + None, + None, + Some(json!({ "reason": reason })), + )?; + let mut state = self.state.lock().map_err(|_| poisoned())?; + state.file.flush() + } + + fn append( + &self, + dir: Direction, + kind: RecordKind, + id: Option, + method: Option, + tool: Option, + payload: Option, + ) -> std::io::Result<()> { + let (payload, payload_sha256, truncated, redacted) = match payload { + None => (None, None, false, false), + Some(original) => { + let sha = payload_hash(&original); + let (value, redacted) = match &self.redactor { + Some(r) => r.redact_value(&original), + None => (original, false), + }; + let (value, truncated) = truncate_payload(value, self.max_payload_bytes); + (Some(value), Some(sha), truncated, redacted) + } + }; + + let mut state = self.state.lock().map_err(|_| poisoned())?; + let mut record = Record { + seq: state.seq, + ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + dir, + kind, + id, + method, + tool, + payload, + payload_sha256, + truncated, + redacted, + prev: state.prev.clone(), + hash: String::new(), + }; + record.hash = record_hash(&record, self.key.as_deref()); + let line = serde_json::to_string(&record)?; + state.file.write_all(line.as_bytes())?; + state.file.write_all(b"\n")?; + state.file.flush()?; + state.seq += 1; + state.prev = record.hash; + Ok(()) + } +} + +fn poisoned() -> std::io::Error { + std::io::Error::other("recorder lock poisoned") +} + +fn tool_of(message: &crate::transport::Message) -> Option { + if message.method.as_deref() == Some("tools/call") { + message + .params + .as_ref() + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + .map(String::from) + } else { + None + } +} + +fn message_payload(message: &crate::transport::Message) -> Option { + if let Some(err) = &message.error { + return Some(json!({ "error": err })); + } + if let Some(result) = &message.result { + return Some(json!({ "result": result })); + } + message.params.as_ref().map(|p| json!({ "params": p })) +} + +// ---------------------------------------------------------------------------- +// Reading and verifying +// ---------------------------------------------------------------------------- + +/// A session file read back from disk. +#[derive(Debug, Clone)] +pub struct Session { + pub path: PathBuf, + pub records: Vec, + /// Lines that did not parse as records (corruption or foreign content). + pub unparsed_lines: Vec, +} + +impl Session { + pub fn load(path: &Path) -> std::io::Result { + let content = std::fs::read_to_string(path)?; + let mut records = Vec::new(); + let mut unparsed_lines = Vec::new(); + for (i, line) in content.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(line) { + Ok(r) => records.push(r), + Err(_) => unparsed_lines.push(i + 1), + } + } + Ok(Self { + path: path.to_path_buf(), + records, + unparsed_lines, + }) + } + + pub fn info(&self) -> Option { + self.records + .iter() + .find(|r| r.kind == RecordKind::SessionStart) + .and_then(|r| r.payload.clone()) + .and_then(|p| serde_json::from_value(p).ok()) + } + + pub fn ended(&self) -> bool { + self.records + .last() + .map(|r| r.kind == RecordKind::SessionEnd) + .unwrap_or(false) + } +} + +/// Outcome of checking a session's hash chain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct VerifyResult { + pub records: usize, + pub ok: bool, + /// Sequence number of the first record whose chain link is wrong. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_bad_seq: Option, + pub problems: Vec, + /// Whether the records claim to be HMAC-signed. + pub signed: bool, + /// Whether a key was available to check signatures. + pub key_checked: bool, +} + +/// Recompute every link in the chain. +/// +/// Without a key, HMAC-signed records can only be checked for sequence and +/// `prev` continuity; the result says so through `key_checked = false`. +pub fn verify(session: &Session, key: Option<&[u8]>) -> VerifyResult { + let signed = session + .records + .first() + .map(|r| r.hash.starts_with("hmac-")) + .unwrap_or(false); + let mut result = VerifyResult { + records: session.records.len(), + ok: true, + first_bad_seq: None, + problems: Vec::new(), + signed, + key_checked: !signed || key.is_some(), + }; + for line in &session.unparsed_lines { + result.ok = false; + result + .problems + .push(format!("line {} is not a valid record", line)); + } + if session.records.is_empty() { + result.ok = false; + result.problems.push("no records".into()); + return result; + } + + let mut expected_prev = GENESIS.to_string(); + for (i, record) in session.records.iter().enumerate() { + let mut bad = Vec::new(); + if record.seq != i as u64 { + bad.push(format!("seq {} at position {}", record.seq, i)); + } + if record.prev != expected_prev { + bad.push("prev does not match the previous record's hash".to_string()); + } + let recompute = if record.hash.starts_with("hmac-") { + key.map(|k| record_hash(record, Some(k))) + } else { + Some(record_hash(record, None)) + }; + if let Some(expected) = recompute { + if expected != record.hash { + bad.push("hash does not match record contents".to_string()); + } + } + if !bad.is_empty() { + result.ok = false; + if result.first_bad_seq.is_none() { + result.first_bad_seq = Some(record.seq); + } + result + .problems + .push(format!("record {}: {}", record.seq, bad.join("; "))); + } + expected_prev = record.hash.clone(); + } + if !session.ended() { + result + .problems + .push("session has no session_end record (still running, or cut off)".into()); + } + result +} + +/// Session files in a directory, newest first. +pub fn list_sessions(dir: &Path) -> std::io::Result> { + let mut files: Vec = match std::fs::read_dir(dir) { + Ok(rd) => rd + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().map(|x| x == "jsonl").unwrap_or(false)) + .collect(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(e) => return Err(e), + }; + files.sort(); + files.reverse(); + Ok(files) +} + +/// Resolve `--session`: `latest`, a full path, a file name, or a session id prefix. +pub fn find_session(dir: &Path, selector: &str) -> std::io::Result> { + let as_path = Path::new(selector); + if as_path.is_file() { + return Ok(Some(as_path.to_path_buf())); + } + let sessions = list_sessions(dir)?; + if selector == "latest" { + return Ok(sessions.into_iter().next()); + } + Ok(sessions.into_iter().find(|p| { + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + name == selector + || name.trim_end_matches(".jsonl").ends_with(selector) + || name.contains(selector) + })) +} + +// ---------------------------------------------------------------------------- +// Timeline rendering +// ---------------------------------------------------------------------------- + +/// One line of a human-readable timeline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TimelineEntry { + pub ts: String, + pub seq: u64, + pub label: String, + pub detail: String, + pub denied: bool, +} + +fn short_json(value: &Value, max: usize) -> String { + let s = value.to_string(); + if s.chars().count() <= max { + return s; + } + let cut: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{}…", cut) +} + +fn time_only(ts: &str) -> String { + ts.get(11..23) + .map(|s| s.to_string()) + .unwrap_or_else(|| ts.to_string()) +} + +/// Turn a session into timeline entries: one per client request, with the +/// matching response folded in, plus decisions and session boundaries. +pub fn timeline(session: &Session) -> Vec { + let mut out = Vec::new(); + let responses: std::collections::HashMap = session + .records + .iter() + .filter(|r| r.kind == RecordKind::Response && r.dir == Direction::GuardToClient) + .filter_map(|r| r.id.as_ref().map(|id| (id.to_string(), r))) + .collect(); + + for record in &session.records { + match record.kind { + RecordKind::SessionStart => { + let info = record + .payload + .clone() + .and_then(|p| serde_json::from_value::(p).ok()) + .unwrap_or_default(); + out.push(TimelineEntry { + ts: time_only(&record.ts), + seq: record.seq, + label: "session start".into(), + detail: format!( + "{}{}{}", + info.upstream.unwrap_or_else(|| "(no upstream)".into()), + info.policy_mode + .map(|m| format!(" policy={}", m)) + .unwrap_or_default(), + if info.signed { " signed" } else { "" } + ), + denied: false, + }); + } + RecordKind::SessionEnd => out.push(TimelineEntry { + ts: time_only(&record.ts), + seq: record.seq, + label: "session end".into(), + detail: record + .payload + .as_ref() + .and_then(|p| p.get("reason")) + .and_then(|r| r.as_str()) + .unwrap_or("") + .to_string(), + denied: false, + }), + RecordKind::Request if record.dir == Direction::ClientToGuard => { + let method = record.method.clone().unwrap_or_default(); + let label = match &record.tool { + Some(t) => format!("{} {}", method, t), + None => method.clone(), + }; + let args = record + .payload + .as_ref() + .and_then(|p| p.get("params")) + .map(|p| { + if method == "tools/call" { + p.get("arguments").cloned().unwrap_or(Value::Null) + } else { + p.clone() + } + }) + .filter(|v| !v.is_null()) + .map(|v| short_json(&v, 80)) + .unwrap_or_default(); + let response = record + .id + .as_ref() + .and_then(|id| responses.get(&id.to_string())); + let (outcome, denied) = match response { + None => ("(no response recorded)".to_string(), false), + Some(r) => match r.payload.as_ref() { + Some(p) if p.get("error").is_some() => { + let err = &p["error"]; + let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(0); + let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or(""); + let denied = matches!(code, -32010 | -32011); + ( + format!("{} {}", if denied { "DENIED" } else { "error" }, msg), + denied, + ) + } + Some(p) => { + let result = p.get("result").cloned().unwrap_or(Value::Null); + let summary = if method == "tools/list" { + format!( + "{} tools", + result + .get("tools") + .and_then(|t| t.as_array()) + .map(|a| a.len()) + .unwrap_or(0) + ) + } else if method == "initialize" { + format!( + "{} {} ({})", + result["serverInfo"]["name"].as_str().unwrap_or("?"), + result["serverInfo"]["version"].as_str().unwrap_or(""), + result["protocolVersion"].as_str().unwrap_or("?") + ) + } else if r.truncated { + "ok (large result, truncated)".to_string() + } else { + format!("ok {}", short_json(&result, 60)) + }; + (summary, false) + } + None => ("ok".to_string(), false), + }, + }; + out.push(TimelineEntry { + ts: time_only(&record.ts), + seq: record.seq, + label, + detail: if args.is_empty() { + outcome + } else { + format!("{} → {}", args, outcome) + }, + denied, + }); + } + RecordKind::Decision => { + let decision = record.method.clone().unwrap_or_default(); + let detail = record + .payload + .as_ref() + .map(|p| { + p.get("reason") + .and_then(|r| r.as_str()) + .map(String::from) + .unwrap_or_else(|| short_json(p, 100)) + }) + .unwrap_or_default(); + out.push(TimelineEntry { + ts: time_only(&record.ts), + seq: record.seq, + label: match &record.tool { + Some(t) => format!("decision {} {}", decision, t), + None => format!("decision {}", decision), + }, + detail, + denied: decision.contains("deny") || decision.contains("hidden"), + }); + } + _ => {} + } + } + out +} + +/// Render entries as text lines. +pub fn render_timeline(entries: &[TimelineEntry]) -> String { + let width = entries + .iter() + .map(|e| e.label.len()) + .max() + .unwrap_or(10) + .min(40); + entries + .iter() + .map(|e| { + let mut label = e.label.clone(); + if label.len() > width { + label.truncate(width); + } + format!( + "{} {:>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::Message; + + fn config() -> RecorderConfig { + RecorderConfig { + enabled: true, + ..RecorderConfig::default() + } + } + + fn call(id: i64, tool: &str, args: Value) -> Message { + Message::request( + json!(id), + "tools/call", + Some(json!({"name": tool, "arguments": args})), + ) + } + + #[test] + fn records_chain_and_verify() { + let dir = tempfile::tempdir().unwrap(); + let rec = Recorder::start(&config(), dir.path(), SessionInfo::default(), &[]).unwrap(); + rec.record_client_message(&call(1, "read_file", json!({"path": "/a"}))) + .unwrap(); + rec.record_outgoing_message( + &Message::response(json!(1), json!({"content": []})), + Some("tools/call"), + Some("read_file"), + ) + .unwrap(); + rec.record_decision("policy_deny", Some("rm"), json!({"reason": "nope"})) + .unwrap(); + rec.finish("stdin closed").unwrap(); + + let session = Session::load(rec.path()).unwrap(); + assert_eq!(session.records.len(), 5); + assert_eq!(session.records[0].kind, RecordKind::SessionStart); + assert_eq!(session.records[0].prev, GENESIS); + assert_eq!(session.records[1].prev, session.records[0].hash); + assert!(session.ended()); + let v = verify(&session, None); + assert!(v.ok, "{:?}", v.problems); + assert!(v.key_checked); + + let entries = timeline(&session); + let text = render_timeline(&entries); + assert!(text.contains("tools/call read_file"), "{}", text); + assert!(text.contains("decision policy_deny rm"), "{}", text); + assert!(text.contains("session end"), "{}", text); + } + + #[test] + fn verify_detects_edit_removal_and_reorder() { + let dir = tempfile::tempdir().unwrap(); + let rec = Recorder::start(&config(), dir.path(), SessionInfo::default(), &[]).unwrap(); + for i in 1..=3 { + rec.record_client_message(&call(i, "read_file", json!({"path": format!("/{}", i)}))) + .unwrap(); + } + rec.finish("done").unwrap(); + let original = std::fs::read_to_string(rec.path()).unwrap(); + let lines: Vec<&str> = original.lines().collect(); + + // Edit a payload. + let edited = original.replace("\"/2\"", "\"/evil\""); + std::fs::write(rec.path(), &edited).unwrap(); + let v = verify(&Session::load(rec.path()).unwrap(), None); + assert!(!v.ok); + assert_eq!(v.first_bad_seq, Some(2)); + + // Remove a record. + let mut removed = lines.clone(); + removed.remove(2); + std::fs::write(rec.path(), removed.join("\n")).unwrap(); + let v = verify(&Session::load(rec.path()).unwrap(), None); + assert!(!v.ok); + assert_eq!(v.first_bad_seq, Some(3)); + + // Reorder two records. + let mut swapped = lines.clone(); + swapped.swap(1, 2); + std::fs::write(rec.path(), swapped.join("\n")).unwrap(); + let v = verify(&Session::load(rec.path()).unwrap(), None); + assert!(!v.ok); + + // Garbage line. + std::fs::write(rec.path(), format!("{}\nnot json\n", original)).unwrap(); + let v = verify(&Session::load(rec.path()).unwrap(), None); + assert!(!v.ok); + assert!(v.problems.iter().any(|p| p.contains("not a valid record"))); + + // Intact again. + std::fs::write(rec.path(), &original).unwrap(); + assert!(verify(&Session::load(rec.path()).unwrap(), None).ok); + } + + #[test] + fn signed_chain_needs_the_key() { + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("MCP_GUARD_TEST_REC_KEY", "correct horse battery staple"); + let cfg = RecorderConfig { + signing_key_env: Some("MCP_GUARD_TEST_REC_KEY".into()), + ..config() + }; + let rec = Recorder::start(&cfg, dir.path(), SessionInfo::default(), &[]).unwrap(); + rec.record_client_message(&call(1, "read_file", json!({}))) + .unwrap(); + rec.finish("done").unwrap(); + let session = Session::load(rec.path()).unwrap(); + assert!(session.records[0].hash.starts_with("hmac-sha256:")); + assert!(session.info().unwrap().signed); + + let with_key = verify(&session, Some(b"correct horse battery staple")); + assert!(with_key.ok && with_key.key_checked); + let wrong_key = verify(&session, Some(b"wrong")); + assert!(!wrong_key.ok); + let no_key = verify(&session, None); + assert!(no_key.ok, "continuity still checks out"); + assert!(!no_key.key_checked); + + // An attacker without the key cannot rechain after editing. + let edited = std::fs::read_to_string(rec.path()) + .unwrap() + .replace("read_file", "rm_rf"); + std::fs::write(rec.path(), edited).unwrap(); + assert!( + !verify( + &Session::load(rec.path()).unwrap(), + Some(b"correct horse battery staple") + ) + .ok + ); + std::env::remove_var("MCP_GUARD_TEST_REC_KEY"); + } + + #[test] + fn payloads_are_redacted_and_hash_of_original_kept() { + let dir = tempfile::tempdir().unwrap(); + let user_rule = RedactionRule { + name: "ticket".into(), + pattern: r"TICKET-\d+".into(), + replacement: "[TICKET]".into(), + }; + let rec = + Recorder::start(&config(), dir.path(), SessionInfo::default(), &[user_rule]).unwrap(); + let args = json!({ + "token": "ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD", + "note": "see TICKET-42", + "url": "https://user:pass@example.com/x", + "pem": "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----" + }); + rec.record_client_message(&call(1, "post", args.clone())) + .unwrap(); + rec.finish("done").unwrap(); + + let text = std::fs::read_to_string(rec.path()).unwrap(); + assert!(!text.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD")); + assert!(text.contains("[REDACTED:github_token]")); + assert!(text.contains("[TICKET]")); + assert!(!text.contains("user:pass@")); + assert!(!text.contains("MIIE")); + let session = Session::load(rec.path()).unwrap(); + let rec1 = &session.records[1]; + assert!(rec1.redacted); + assert_eq!( + rec1.payload_sha256.as_deref(), + Some(payload_hash(&json!({"params": {"name": "post", "arguments": args}})).as_str()) + ); + assert!( + verify(&session, None).ok, + "hash covers the stored (redacted) record" + ); + } + + #[test] + fn oversized_payloads_are_truncated() { + let dir = tempfile::tempdir().unwrap(); + let cfg = RecorderConfig { + max_payload_bytes: 200, + ..config() + }; + let rec = Recorder::start(&cfg, dir.path(), SessionInfo::default(), &[]).unwrap(); + let big = "x".repeat(5000); + rec.record_outgoing_message( + &Message::response( + json!(1), + json!({"content": [{"type": "text", "text": big}]}), + ), + Some("tools/call"), + Some("read_file"), + ) + .unwrap(); + rec.finish("done").unwrap(); + let session = Session::load(rec.path()).unwrap(); + let r = &session.records[1]; + assert!(r.truncated); + assert_eq!(r.payload.as_ref().unwrap()["_truncated"], true); + assert!(r.payload.as_ref().unwrap().to_string().len() < 2000); + assert!(verify(&session, None).ok); + } + + #[test] + fn include_results_false_keeps_errors() { + let dir = tempfile::tempdir().unwrap(); + let cfg = RecorderConfig { + include_results: false, + ..config() + }; + let rec = Recorder::start(&cfg, dir.path(), SessionInfo::default(), &[]).unwrap(); + rec.record_outgoing_message( + &Message::response(json!(1), json!({"ok": true})), + None, + None, + ) + .unwrap(); + rec.record_outgoing_message( + &Message::error_response(Some(json!(2)), -32010, "denied"), + None, + None, + ) + .unwrap(); + rec.finish("done").unwrap(); + let session = Session::load(rec.path()).unwrap(); + let kinds: Vec = session.records.iter().map(|r| r.kind).collect(); + assert_eq!( + kinds, + vec![ + RecordKind::SessionStart, + RecordKind::Response, + RecordKind::SessionEnd + ] + ); + assert!(session.records[1] + .payload + .as_ref() + .unwrap() + .get("error") + .is_some()); + } + + #[test] + fn list_and_find_sessions() { + let dir = tempfile::tempdir().unwrap(); + assert!(list_sessions(dir.path()).unwrap().is_empty()); + assert!(list_sessions(&dir.path().join("missing")) + .unwrap() + .is_empty()); + let a = Recorder::start(&config(), dir.path(), SessionInfo::default(), &[]).unwrap(); + a.finish("x").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + let b = Recorder::start(&config(), dir.path(), SessionInfo::default(), &[]).unwrap(); + b.finish("x").unwrap(); + let sessions = list_sessions(dir.path()).unwrap(); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0], b.path(), "newest first"); + assert_eq!( + find_session(dir.path(), "latest").unwrap().unwrap(), + b.path() + ); + assert_eq!( + find_session(dir.path(), a.session_id()).unwrap().unwrap(), + a.path() + ); + assert_eq!( + find_session(dir.path(), a.path().to_str().unwrap()) + .unwrap() + .unwrap(), + a.path() + ); + assert!(find_session(dir.path(), "nope").unwrap().is_none()); + } +} diff --git a/crates/mcp-guard-core/src/server/mod.rs b/crates/mcp-guard-core/src/server/mod.rs index 8844501..73f27ac 100644 --- a/crates/mcp-guard-core/src/server/mod.rs +++ b/crates/mcp-guard-core/src/server/mod.rs @@ -2009,6 +2009,7 @@ mod tests { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -2131,6 +2132,7 @@ mod tests { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; diff --git a/crates/mcp-guard-core/src/tier.rs b/crates/mcp-guard-core/src/tier.rs index 5b3be06..126da43 100644 --- a/crates/mcp-guard-core/src/tier.rs +++ b/crates/mcp-guard-core/src/tier.rs @@ -268,6 +268,7 @@ mod tests { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, } diff --git a/crates/mcp-guard-core/tests/integration_tests.rs b/crates/mcp-guard-core/tests/integration_tests.rs index ffb17fe..34de7f3 100644 --- a/crates/mcp-guard-core/tests/integration_tests.rs +++ b/crates/mcp-guard-core/tests/integration_tests.rs @@ -58,6 +58,7 @@ fn test_config_validation_stdio() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -82,6 +83,7 @@ fn test_config_validation_stdio_missing_command() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -110,6 +112,7 @@ fn test_config_validation_http_missing_url() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -326,6 +329,7 @@ fn test_config_validation_http_valid() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -351,6 +355,7 @@ fn test_config_validation_sse_valid() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -376,6 +381,7 @@ fn test_config_validation_http_requires_pro() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -402,6 +408,7 @@ fn test_config_validation_sse_requires_pro() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -430,6 +437,7 @@ fn test_config_validation_sse_missing_url() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -461,6 +469,7 @@ fn test_config_validation_port_zero() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -491,6 +500,7 @@ fn test_config_validation_rate_limit_zero_rps() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -524,6 +534,7 @@ fn test_config_validation_rate_limit_zero_burst() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -562,6 +573,7 @@ fn test_config_validation_audit_invalid_export_url() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -600,6 +612,7 @@ fn test_config_validation_audit_zero_batch_size() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -634,6 +647,7 @@ fn test_config_validation_tracing_invalid_sample_rate() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -818,6 +832,7 @@ async fn test_health_endpoint_response_structure() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -927,6 +942,7 @@ async fn test_live_endpoint() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -995,6 +1011,7 @@ async fn test_ready_endpoint_when_ready() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -1068,6 +1085,7 @@ async fn test_ready_endpoint_when_not_ready() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -1215,6 +1233,7 @@ async fn test_oauth_authorize_not_configured() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -1288,6 +1307,7 @@ async fn test_oauth_authorize_generates_redirect() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -1392,6 +1412,7 @@ async fn test_oauth_callback_rejects_missing_state() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -1481,6 +1502,7 @@ async fn test_oauth_callback_rejects_invalid_state() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -1570,6 +1592,7 @@ async fn test_oauth_callback_handles_provider_error() { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -1750,6 +1773,7 @@ async fn test_routes_endpoint_lists_servers() { ], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; @@ -1834,6 +1858,7 @@ async fn test_routes_endpoint_unavailable_when_single_server() { servers: vec![], // No multi-server routing }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, }; diff --git a/crates/mcp-guard-core/tests/oauth_flow_tests.rs b/crates/mcp-guard-core/tests/oauth_flow_tests.rs index 80da6dd..a1a1477 100644 --- a/crates/mcp-guard-core/tests/oauth_flow_tests.rs +++ b/crates/mcp-guard-core/tests/oauth_flow_tests.rs @@ -49,6 +49,7 @@ fn create_test_config() -> Config { servers: vec![], }, policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, } diff --git a/crates/mcp-guard-core/tests/server_lifecycle_tests.rs b/crates/mcp-guard-core/tests/server_lifecycle_tests.rs index 2795569..6ea24e2 100644 --- a/crates/mcp-guard-core/tests/server_lifecycle_tests.rs +++ b/crates/mcp-guard-core/tests/server_lifecycle_tests.rs @@ -51,6 +51,7 @@ fn create_test_config(port: u16) -> Config { audit: AuditConfig::default(), tracing: TracingConfig::default(), policy: Default::default(), + recorder: Default::default(), database_url: None, stripe_secret_key: None, } diff --git a/docs/cli.md b/docs/cli.md index da66ab4..b0c8aee 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -582,6 +582,31 @@ While the lockfile is out of date, `serve` hides the changed and new tools and r --- +### replay + +Show a recorded session as a timeline. + +```bash +mcp-guard replay --config [--session latest||] [--list] [--denied] [--tool ] [--json] +mcp-guard replay --dir ... +``` + +Each client request is one line with its arguments and outcome; denials and guard decisions are marked. The last line reports whether the hash chain is intact. `--list` shows every session with record and decision counts. + +--- + +### verify + +Check a recording's hash chain. + +```bash +mcp-guard verify --config [--session latest|all||] [--dir ] [--json] +``` + +Exit 1 if any checked session has been edited, truncated, reordered or has a corrupt line. For signed recordings the signing key is read from the variable named by `[recorder] signing_key_env`; without it, continuity is checked and the result is marked `OK*`. + +--- + ### policy test Evaluate the config's `[policy]` against one tool call and print the decision. diff --git a/docs/configuration.md b/docs/configuration.md index 27a0d06..8c1dbb0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -636,6 +636,29 @@ reason = "writes outside the project are blocked" path = { not_starts_with = "/home/me/project/" } ``` +## [recorder] Section + +Flight recorder for `mcp-guard serve`: tamper-evident, redacted, per-session recording. Full guide: [flight-recorder.md](flight-recorder.md). + +```toml +[recorder] +enabled = true +# dir = "recordings" +# max_payload_bytes = 65536 +# include_results = true +# redact = true +# signing_key_env = "MCP_GUARD_RECORDER_KEY" +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `false` | Record sessions. `mcp-guard wrap` sets this to `true` | +| `dir` | path | `.recordings/` | Where session files go, relative to the config file unless absolute | +| `max_payload_bytes` | integer | `65536` | Larger payloads are stored as a preview plus the hash of the full payload | +| `include_results` | bool | `true` | Record responses. When `false`, only requests, decisions and errors are kept | +| `redact` | bool | `true` | Apply built-in secret patterns and `[audit] redaction_rules` before writing | +| `signing_key_env` | string | none | Environment variable holding an HMAC key; hashes become HMAC-SHA256 | + ## Complete Examples ### Minimal Development Configuration diff --git a/docs/flight-recorder.md b/docs/flight-recorder.md new file mode 100644 index 0000000..29c5418 --- /dev/null +++ b/docs/flight-recorder.md @@ -0,0 +1,134 @@ +# Flight recorder: replay and verify + +The flight recorder keeps a tamper-evident, redacted record of everything an agent did through `mcp-guard serve`: every request it sent, every response it got, and every decision the guard made in between. `mcp-guard replay` renders a session as a timeline. `mcp-guard verify` proves the record has not been edited. + +Audit logs tell you *that* a tool was called. The recorder tells you *what was asked, what came back, and what was blocked*, in order, with a chain of hashes that an auditor or incident responder can check. + +## Turning it on + +`mcp-guard wrap` enables it for every server it wraps, with recordings under `~/.config/mcp-guard/recordings/-/`. For a hand-written config: + +```toml +[recorder] +enabled = true +# dir = "recordings" # default: .recordings/ next to the config +# max_payload_bytes = 65536 # larger payloads are stored as a preview plus a hash +# include_results = true # false records requests, decisions and errors only +# redact = true # built-in secret patterns plus [audit] redaction_rules +# signing_key_env = "MCP_GUARD_RECORDER_KEY" # HMAC instead of plain hashes (see below) +``` + +`mcp-guard doctor` reports `RECORDER_DISABLED` for guarded servers that have it off. + +## What a session looks like + +```bash +mcp-guard replay --config ~/.config/mcp-guard/claude-desktop/filesystem.toml +``` + +``` +Session 7f3a9c1d2e4b /Users/me/.config/mcp-guard/recordings/claude-desktop-filesystem/20261022-181502-7f3a9c1d2e4b.jsonl +Upstream npx -y @modelcontextprotocol/server-filesystem /Users/me +Records 41 Policy mode: enforce + +18:15:02.114 session start npx -y @modelcontextprotocol/server-filesystem /Users/me policy=enforce +18:15:02.301 initialize secure-filesystem-server 0.6.2 (2025-06-18) +18:15:02.420 tools/list 11 tools +18:15:02.421 decision tool_hidden_policy delete_file ✗ tool 'delete_file' is not allowed by policy +18:15:09.877 tools/call read_file {"path":"/Users/me/project/README.md"} → ok {"content":[{"type":"text","text":"# my project… +18:15:14.002 tools/call write_file {"path":"/etc/hosts","content":"127.0.0.1 evil"} → ✗ DENIED mcp-guard: call to 'write_file' denied by policy: writes outside the project are blocked +18:15:14.002 decision policy_deny write_file ✗ writes outside the project are blocked +18:17:40.556 session end stdin closed + +Chain OK +``` + +Useful views: + +```bash +mcp-guard replay --config X --list # every session, newest first +mcp-guard replay --config X --session 7f3a9c # by id prefix, file name, or path +mcp-guard replay --config X --denied # only denials and guard decisions +mcp-guard replay --config X --tool write_file # one tool's calls +mcp-guard replay --config X --json # raw records, one JSON object per line +mcp-guard replay --dir /path/to/recordings # without a config +``` + +## Record format + +One JSONL file per session, named `-.jsonl`. Each line: + +```json +{ + "seq": 5, + "ts": "2026-10-22T18:15:14.002Z", + "dir": "client_to_guard", + "kind": "request", + "id": 4, + "method": "tools/call", + "tool": "write_file", + "payload": {"params": {"name": "write_file", "arguments": {"path": "/etc/hosts", "content": "127.0.0.1 evil"}}}, + "payload_sha256": "sha256:9c1e…", + "prev": "sha256:4b8d…", + "hash": "sha256:a07f…" +} +``` + +| Field | Meaning | +|---|---| +| `seq` | Position in the session, from 0 | +| `dir` | `client_to_guard`, `guard_to_client`, or `guard` for session boundaries and decisions | +| `kind` | `session_start`, `session_end`, `request`, `response`, `notification`, `decision` | +| `method` | JSON-RPC method, or the decision type for `decision` records | +| `tool` | Tool name for `tools/call` traffic and decisions | +| `payload` | Redacted, possibly truncated: `params`, `result`, `error`, or decision detail | +| `payload_sha256` | SHA-256 of the full original payload, before redaction and truncation | +| `truncated`, `redacted` | Present and `true` when either happened | +| `prev` | `hash` of the previous record; a fixed genesis value for `seq` 0 | +| `hash` | Hash of this record with `hash` removed, prefixed by `prev` | + +Decision types: `policy_deny`, `pin_deny`, `policy_would_deny` (observe mode), `tool_hidden_policy`, `tool_hidden_pin`. + +The `session_start` payload records the session id, start time, guard version, upstream command, config path, policy mode and whether the recording is signed. `session_end` records why the session ended. + +## Redaction + +Payloads are redacted before they are hashed and written, so a recording can be shared without leaking credentials. Built-in patterns cover private key blocks, AWS, GitHub, Slack, OpenAI, Anthropic, Google and Stripe keys, mcp-guard API keys, JWTs, bearer tokens and `user:password@` URLs. Replacements name the pattern, for example `[REDACTED:github_token]`. Your own `[audit] redaction_rules` are applied as well. + +`payload_sha256` is computed over the original payload. If you still hold the original (a file the agent read, a request body you logged elsewhere), you can hash it and match it to the record without the recording itself containing the secret. + +Redaction is pattern-based. It will not catch a secret that does not look like one. Review a recording before sending it to a third party. + +## Tamper evidence + +Every record's `hash` covers its own contents and the previous record's hash. Editing, deleting, inserting or reordering any record changes the hash chain from that point on, and `verify` reports the first record where the chain no longer holds: + +```bash +$ mcp-guard verify --config X --session all +OK .../20261022-181502-7f3a9c1d2e4b.jsonl (41 records) +BROKEN .../20261021-093311-c02d44e1aa90.jsonl (12 records) + record 7: hash does not match record contents + record 8: prev does not match the previous record's hash +``` + +`verify` exits 1 when any session is broken, so it can run in CI or a cron job. `--json` gives the result per file. + +### Plain hashes versus signed recordings + +With plain SHA-256, anyone who can edit the file can also recompute the chain. That still catches accidental corruption, truncation and careless edits, and it makes a deliberate forgery a deliberate act rather than a slip. It does not stop someone with write access and intent. + +Set `signing_key_env` to the name of an environment variable holding a secret, and hashes become HMAC-SHA256 under that key. The writing process needs the key; a forger needs it too. Keep the key somewhere the agent's machine cannot read it at rest (a secrets manager, a hardware token, a separate host that pulls the recordings), and `verify` on that other host becomes a real integrity check. Without the key, `verify` still checks continuity and says so (`OK*`). + +Signed recordings mark `signed: true` in their `session_start` record. + +## Operational notes + +- Recording is append-only and flushed per record. A crash loses at most the record being written; the session simply has no `session_end`, which `verify` points out. +- Large tool results are truncated to `max_payload_bytes` with a preview and the hash of the full result. Set `include_results = false` to keep only requests, decisions and errors. +- Recordings are not rotated or deleted automatically. Prune the directory on your own schedule; each session is one file. +- The recorder never writes to stdout, which carries the protocol, and a recorder failure never blocks the agent. A failed write is logged to stderr and the session continues unrecorded from that point. +- Gateway mode (`mcp-guard run`) does not record yet. It writes the audit log. + +## What is on the roadmap + +Recording in gateway mode keyed by identity, retention policies, and exporting a session as a self-contained HTML report. diff --git a/docs/local-mode.md b/docs/local-mode.md index 3b9c964..f9a22a4 100644 --- a/docs/local-mode.md +++ b/docs/local-mode.md @@ -50,6 +50,7 @@ Finding codes: | `PINNING_DISABLED` | WARN | A guarded server has `pin_tools = false` | | `NOT_YET_PINNED` | INFO | Pinning is on but the lockfile has not been written yet | | `AUDIT_DISABLED` | WARN | A guarded server has audit logging off | +| `RECORDER_DISABLED` | INFO | A guarded server has the flight recorder off | | `CLIENT_CONFIG_UNREADABLE` | HIGH | The client file is not valid JSON | ## wrap @@ -63,7 +64,7 @@ mcp-guard wrap --client ./some/mcp.json --allow "read_*,list_*" --dry-run For every stdio server it: -1. Writes a guard config to `//.toml` with the original `command` and `args` as the upstream, an audit log path, and a `[policy]` section built from `--allow`, `--deny` and `--no-pin`. +1. Writes a guard config to `//.toml` with the original `command` and `args` as the upstream, an audit log path, a `[policy]` section built from `--allow`, `--deny` and `--no-pin`, and a `[recorder]` section pointing at `/recordings/-/`. 2. Backs up the client file to `.bak-`. 3. Replaces the entry's `command` with the path of the running `mcp-guard` binary (override with `--guard-bin`) and its `args` with `--config serve`. @@ -224,6 +225,10 @@ Lockfile format: } ``` +## Flight recorder + +Beyond the audit log, `wrap` turns on the flight recorder: a per-session, tamper-evident, redacted record of every request, response and guard decision. `mcp-guard replay --config ` renders a session as a timeline; `mcp-guard verify` checks it has not been edited. See [flight-recorder.md](flight-recorder.md). + ## Audit log Each generated config logs to `/logs/-.audit.jsonl`. One JSON object per line, with the event type (`ToolCall`, `AuthzDenied`), the tool name, the request id and a timestamp. Redaction rules from `[audit] redaction_rules` apply. `audit.stdout` is ignored in serve mode because stdout carries the protocol. diff --git a/templates/mcp-guard.toml b/templates/mcp-guard.toml index 1481768..b1d821b 100644 --- a/templates/mcp-guard.toml +++ b/templates/mcp-guard.toml @@ -460,3 +460,19 @@ mode = "enforce" # action = "deny" # [policy.rules.when] # path = { contains = [".ssh", ".aws", ".env"] } + +# ============================================================================= +# Flight Recorder (used by `mcp-guard serve`, the stdio proxy mode) +# ============================================================================= +# Tamper-evident, redacted, per-session record of every request, response and +# guard decision. `mcp-guard replay` shows a session; `mcp-guard verify` checks +# the hash chain. `mcp-guard wrap` enables this for every wrapped server. +# See docs/flight-recorder.md. + +[recorder] +enabled = false +# dir = "recordings" # default: .recordings/ next to this file +# max_payload_bytes = 65536 # larger payloads: preview + hash of the full payload +# include_results = true # false: requests, decisions and errors only +# redact = true # built-in secret patterns + [audit] redaction_rules +# signing_key_env = "MCP_GUARD_RECORDER_KEY" # HMAC-SHA256 chain; keep the key off this machine