Skip to main content

agentsec_core/plain_mode/
mod.rs

1//! Plain Mode — temporarily disable MCP servers by renaming `.mcp.json`
2//! files to `.mcp.json.suspect` and (optionally) writing an empty stub in
3//! their place.
4//!
5//! ## When to use it
6//!
7//! User suspects a newly-installed MCP server is malicious and wants to
8//! start a clean Agent session **right now**, without manually editing
9//! configs. `agentsec plain enable` renames targets to `.suspect`; the
10//! Agent then starts with an empty MCP world. `agentsec plain restore`
11//! puts everything back.
12//!
13//! ## What gets renamed
14//!
15//! Plain Mode only touches **standalone `.mcp.json` files**. It never
16//! edits `.claude.json` directly, because that file mixes MCP server
17//! config with hooks / permissions / per-project state that we don't
18//! want to wipe.
19//!
20//! Default target: `<cwd>/.mcp.json`. Callers can pass additional
21//! absolute paths (typically per-project `.mcp.json` discovered from a
22//! scan).
23//!
24//! ## Ledger
25//!
26//! [`PLAIN_LEDGER_FILENAME`] under [`crate::Paths::home`] tracks which
27//! files were renamed so [`restore`] can put them back. The ledger
28//! existing with non-empty `entries` is the canonical signal that Plain
29//! Mode is active.
30//!
31//! ## Invariants
32//!
33//! - [`enable`] is **idempotent within a session**: if the target's
34//!   `.suspect` sibling already exists, enable skips that target (and
35//!   reports the skip) rather than clobbering the prior backup.
36//! - [`restore`] removes the ledger on success. Repeat calls with an
37//!   absent ledger are no-ops.
38//! - Dry-run mode performs zero filesystem mutation and returns the same
39//!   shape of result so callers can preview.
40
41use std::fs;
42use std::path::{Path, PathBuf};
43
44use serde::{Deserialize, Serialize};
45
46use crate::Paths;
47use crate::error::Result;
48
49/// Filename of the ledger file under [`Paths::home`].
50pub const PLAIN_LEDGER_FILENAME: &str = "plain-mode.json";
51
52/// Suffix appended to a renamed file (so `.mcp.json` becomes
53/// `.mcp.json.suspect`).
54pub const SUSPECT_SUFFIX: &str = ".suspect";
55
56/// Content of the stub written at the original path while Plain Mode is
57/// active. Empty MCP world; the Agent sees zero servers configured.
58pub const PLAIN_STUB_BODY: &str = "{\n  \"mcpServers\": {}\n}\n";
59
60/// One row in the persisted ledger.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct LedgerEntry {
63    /// Original absolute path of the renamed file (the one the Agent
64    /// reads). After `enable`, a stub lives here.
65    pub original: PathBuf,
66    /// `<original><SUSPECT_SUFFIX>` — where the real file was moved to.
67    pub suspect: PathBuf,
68    /// `true` if a plain-config stub was written at `original` during
69    /// enable (the common case; `false` only when the caller asked to
70    /// skip stub creation).
71    pub stub_created: bool,
72}
73
74/// Full ledger persisted to [`PLAIN_LEDGER_FILENAME`].
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct PlainLedger {
77    /// All ledger entries, in enable order.
78    pub entries: Vec<LedgerEntry>,
79}
80
81/// One row of [`enable`]'s outcome.
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83pub struct EnableRow {
84    /// Original path the caller asked us to disable.
85    pub original: PathBuf,
86    /// Action actually taken for this target.
87    pub action: EnableAction,
88}
89
90/// Outcome variants for a single target.
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub enum EnableAction {
93    /// File was renamed to `<original>.suspect` and a stub was written
94    /// at `original`.
95    Renamed,
96    /// File was renamed to `<original>.suspect` and **no** stub was
97    /// written (caller opted out).
98    RenamedNoStub,
99    /// `<original>.suspect` already existed; we did nothing to avoid
100    /// clobbering a prior backup.
101    SkippedAlreadySuspect,
102    /// `original` does not exist; nothing to disable.
103    SkippedMissing,
104    /// `--dry-run`: would have renamed, but didn't.
105    WouldRename,
106    /// `--dry-run`: would have skipped (one of the Skipped reasons).
107    WouldSkip { reason: String },
108}
109
110/// Outcome of [`enable`].
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct EnableOutcome {
113    /// Per-target action log.
114    pub rows: Vec<EnableRow>,
115    /// `true` when the call ran without `dry_run`.
116    pub applied: bool,
117    /// Ledger path that was (or would be) written.
118    pub ledger_path: PathBuf,
119}
120
121/// One row of [`restore`]'s outcome.
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123pub struct RestoreRow {
124    /// The `original` path from the ledger.
125    pub original: PathBuf,
126    /// Action actually taken.
127    pub action: RestoreAction,
128}
129
130/// Outcome variants for restoring a single ledger entry.
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub enum RestoreAction {
133    /// `<original>.suspect` was renamed back to `original`. Any stub
134    /// previously at `original` was overwritten.
135    Restored,
136    /// The `.suspect` file was not found; the ledger entry is stale.
137    SuspectMissing,
138    /// `--dry-run`: would have restored.
139    WouldRestore,
140    /// `--dry-run`: would have skipped (suspect missing).
141    WouldSkip { reason: String },
142}
143
144/// Outcome of [`restore`].
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct RestoreOutcome {
147    /// Per-entry action log.
148    pub rows: Vec<RestoreRow>,
149    /// `true` when the call ran without `dry_run`.
150    pub applied: bool,
151    /// Ledger path that was (or would be) removed.
152    pub ledger_path: PathBuf,
153}
154
155/// Resolved Plain Mode status — what the on-disk ledger says right now.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct PlainStatus {
158    /// `true` when the ledger exists and has at least one entry.
159    pub active: bool,
160    /// All ledger entries (empty when `active == false`).
161    pub entries: Vec<LedgerEntry>,
162    /// Ledger path that was read (whether or not it exists).
163    pub ledger_path: PathBuf,
164}
165
166/// Read [`PlainStatus`] from disk.
167///
168/// # Errors
169///
170/// - [`crate::Error::Io`] on ledger read failure.
171/// - [`crate::Error::Json`] on ledger parse failure.
172pub fn status(paths: &Paths) -> Result<PlainStatus> {
173    let ledger_path = paths.home.join(PLAIN_LEDGER_FILENAME);
174    let entries = if ledger_path.exists() {
175        let body = fs::read_to_string(&ledger_path)?;
176        serde_json::from_str::<PlainLedger>(&body)?.entries
177    } else {
178        Vec::new()
179    };
180    Ok(PlainStatus {
181        active: !entries.is_empty(),
182        entries,
183        ledger_path,
184    })
185}
186
187/// Enable Plain Mode for the given target paths.
188///
189/// Each target is processed independently; failures on one target don't
190/// abort the others (they get a `Skipped*` action in the outcome). The
191/// ledger is written only after every target has been processed and only
192/// when `dry_run` is false.
193///
194/// # Errors
195///
196/// - [`crate::Error::Io`] on ledger directory creation or write failure.
197/// - [`crate::Error::Json`] on ledger serialization failure.
198pub fn enable(
199    paths: &Paths,
200    targets: &[PathBuf],
201    dry_run: bool,
202    write_stub: bool,
203) -> Result<EnableOutcome> {
204    let ledger_path = paths.home.join(PLAIN_LEDGER_FILENAME);
205    let mut rows = Vec::with_capacity(targets.len());
206    let mut new_entries = Vec::new();
207
208    for target in targets {
209        let suspect = suspect_path(target);
210        let action = if !target.exists() {
211            if dry_run {
212                EnableAction::WouldSkip {
213                    reason: "target does not exist".into(),
214                }
215            } else {
216                EnableAction::SkippedMissing
217            }
218        } else if suspect.exists() {
219            if dry_run {
220                EnableAction::WouldSkip {
221                    reason: "suspect backup already present".into(),
222                }
223            } else {
224                EnableAction::SkippedAlreadySuspect
225            }
226        } else if dry_run {
227            EnableAction::WouldRename
228        } else {
229            fs::rename(target, &suspect)?;
230            if write_stub {
231                fs::write(target, PLAIN_STUB_BODY)?;
232            }
233            new_entries.push(LedgerEntry {
234                original: target.clone(),
235                suspect: suspect.clone(),
236                stub_created: write_stub,
237            });
238            if write_stub {
239                EnableAction::Renamed
240            } else {
241                EnableAction::RenamedNoStub
242            }
243        };
244        rows.push(EnableRow {
245            original: target.clone(),
246            action,
247        });
248    }
249
250    if !dry_run && !new_entries.is_empty() {
251        fs::create_dir_all(&paths.home)?;
252        // Merge with any prior ledger so we don't lose earlier entries
253        // (idempotent enable across sessions).
254        let mut ledger = if ledger_path.exists() {
255            let body = fs::read_to_string(&ledger_path)?;
256            serde_json::from_str::<PlainLedger>(&body).unwrap_or_default()
257        } else {
258            PlainLedger::default()
259        };
260        ledger.entries.extend(new_entries);
261        let body = serde_json::to_string_pretty(&ledger)?;
262        fs::write(&ledger_path, body)?;
263    }
264
265    Ok(EnableOutcome {
266        rows,
267        applied: !dry_run,
268        ledger_path,
269    })
270}
271
272/// Restore everything in the ledger.
273///
274/// For each entry: rename `suspect` back to `original` (overwriting any
275/// stub that lived there), then drop the entry. Entries whose suspect
276/// file is missing are surfaced as `SuspectMissing` but don't abort the
277/// rest. On success the ledger file is removed.
278///
279/// # Errors
280///
281/// - [`crate::Error::Io`] on rename / write / delete failure.
282/// - [`crate::Error::Json`] on ledger parse failure.
283pub fn restore(paths: &Paths, dry_run: bool) -> Result<RestoreOutcome> {
284    let ledger_path = paths.home.join(PLAIN_LEDGER_FILENAME);
285    if !ledger_path.exists() {
286        return Ok(RestoreOutcome {
287            rows: Vec::new(),
288            applied: !dry_run,
289            ledger_path,
290        });
291    }
292
293    let body = fs::read_to_string(&ledger_path)?;
294    let ledger: PlainLedger = serde_json::from_str(&body)?;
295    let mut rows = Vec::with_capacity(ledger.entries.len());
296
297    for entry in &ledger.entries {
298        let action = if !entry.suspect.exists() {
299            if dry_run {
300                RestoreAction::WouldSkip {
301                    reason: "suspect file missing".into(),
302                }
303            } else {
304                RestoreAction::SuspectMissing
305            }
306        } else if dry_run {
307            RestoreAction::WouldRestore
308        } else {
309            // Remove any stub / file currently at `original` so the rename
310            // below succeeds on platforms that don't overwrite atomically.
311            if entry.original.exists() {
312                fs::remove_file(&entry.original)?;
313            }
314            fs::rename(&entry.suspect, &entry.original)?;
315            RestoreAction::Restored
316        };
317        rows.push(RestoreRow {
318            original: entry.original.clone(),
319            action,
320        });
321    }
322
323    if !dry_run {
324        fs::remove_file(&ledger_path)?;
325    }
326
327    Ok(RestoreOutcome {
328        rows,
329        applied: !dry_run,
330        ledger_path,
331    })
332}
333
334/// Compute the suspect-sidecar path for a given original path.
335fn suspect_path(original: &Path) -> PathBuf {
336    let mut s = original.as_os_str().to_os_string();
337    s.push(SUSPECT_SUFFIX);
338    PathBuf::from(s)
339}
340
341/// Default Plain Mode targets — currently just `<cwd>/.mcp.json`.
342/// Callers can extend this list with their own absolute paths.
343pub fn default_targets() -> Vec<PathBuf> {
344    vec![PathBuf::from(".mcp.json")]
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
352        Paths {
353            home: tmp.path().to_path_buf(),
354            user_home: tmp.path().to_path_buf(),
355        }
356    }
357
358    fn write(p: &Path, body: &str) {
359        std::fs::write(p, body).unwrap();
360    }
361
362    #[test]
363    fn enable_dry_run_does_not_touch_filesystem() {
364        let tmp = tempfile::tempdir().unwrap();
365        let target = tmp.path().join(".mcp.json");
366        write(&target, "{\"mcpServers\":{\"x\":1}}");
367        let original_body = std::fs::read_to_string(&target).unwrap();
368
369        let outcome = enable(&paths_for(&tmp), &[target.clone()], true, true).unwrap();
370        assert!(!outcome.applied);
371        assert_eq!(outcome.rows[0].action, EnableAction::WouldRename);
372        // Original file untouched, suspect not created, no ledger.
373        assert!(target.exists());
374        assert_eq!(std::fs::read_to_string(&target).unwrap(), original_body);
375        assert!(!suspect_path(&target).exists());
376        assert!(!outcome.ledger_path.exists());
377    }
378
379    #[test]
380    fn enable_renames_and_writes_stub() {
381        let tmp = tempfile::tempdir().unwrap();
382        let target = tmp.path().join(".mcp.json");
383        write(
384            &target,
385            "{\"mcpServers\":{\"original\":{\"command\":\"x\"}}}",
386        );
387        let suspect = suspect_path(&target);
388
389        let outcome = enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
390        assert!(outcome.applied);
391        assert_eq!(outcome.rows[0].action, EnableAction::Renamed);
392        assert!(suspect.exists(), "suspect file must be created");
393        assert!(target.exists(), "stub must be at original path");
394        let stub = std::fs::read_to_string(&target).unwrap();
395        assert_eq!(stub, PLAIN_STUB_BODY);
396        assert!(outcome.ledger_path.exists());
397    }
398
399    #[test]
400    fn enable_skips_when_suspect_already_present() {
401        let tmp = tempfile::tempdir().unwrap();
402        let target = tmp.path().join(".mcp.json");
403        write(&target, "{}");
404        write(&suspect_path(&target), "prior-backup");
405
406        let outcome = enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
407        assert_eq!(outcome.rows[0].action, EnableAction::SkippedAlreadySuspect);
408        // The prior suspect must NOT be overwritten.
409        assert_eq!(
410            std::fs::read_to_string(suspect_path(&target)).unwrap(),
411            "prior-backup"
412        );
413        // No ledger row was added (since nothing happened).
414        assert!(!outcome.ledger_path.exists());
415    }
416
417    #[test]
418    fn restore_round_trip_returns_original_content() {
419        let tmp = tempfile::tempdir().unwrap();
420        let target = tmp.path().join(".mcp.json");
421        let body = "{\"mcpServers\":{\"real\":{\"command\":\"x\"}}}";
422        write(&target, body);
423
424        enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
425        assert_ne!(std::fs::read_to_string(&target).unwrap(), body);
426
427        let restore_outcome = restore(&paths_for(&tmp), false).unwrap();
428        assert!(restore_outcome.applied);
429        assert_eq!(restore_outcome.rows[0].action, RestoreAction::Restored);
430        // Original content is back, ledger gone.
431        assert_eq!(std::fs::read_to_string(&target).unwrap(), body);
432        assert!(!suspect_path(&target).exists());
433        assert!(!restore_outcome.ledger_path.exists());
434    }
435
436    #[test]
437    fn status_reflects_ledger_state() {
438        let tmp = tempfile::tempdir().unwrap();
439        let target = tmp.path().join(".mcp.json");
440        write(&target, "{}");
441
442        let before = status(&paths_for(&tmp)).unwrap();
443        assert!(!before.active);
444        assert!(before.entries.is_empty());
445
446        enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
447        let during = status(&paths_for(&tmp)).unwrap();
448        assert!(during.active);
449        assert_eq!(during.entries.len(), 1);
450        assert_eq!(during.entries[0].original, target);
451
452        restore(&paths_for(&tmp), false).unwrap();
453        let after = status(&paths_for(&tmp)).unwrap();
454        assert!(!after.active);
455        assert!(after.entries.is_empty());
456    }
457
458    #[test]
459    fn restore_with_no_ledger_is_a_noop() {
460        let tmp = tempfile::tempdir().unwrap();
461        let outcome = restore(&paths_for(&tmp), false).unwrap();
462        assert!(outcome.rows.is_empty());
463        // No error, no ledger left behind.
464        assert!(!outcome.ledger_path.exists());
465    }
466
467    #[test]
468    fn missing_target_yields_skipped_missing() {
469        let tmp = tempfile::tempdir().unwrap();
470        let outcome = enable(
471            &paths_for(&tmp),
472            &[tmp.path().join("not-there.json")],
473            false,
474            true,
475        )
476        .unwrap();
477        assert_eq!(outcome.rows[0].action, EnableAction::SkippedMissing);
478        assert!(!outcome.ledger_path.exists());
479    }
480}