Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- **Attack graph Phase 3c polish** — FixKind registry with runtime `graph_mutate` engine (apply registry `mutates` and simulate template elimination), inventory multi-server graph layer (`graph_inventory`), counterfactual fix simulation on paths, default-on counterfactuals and UI compression (`--no-attack-graph-counterfactuals`, `--no-attack-graph-compress-ui`), dashboard layer filter + policy/inferred edge styling, `mcts doctor --suggest-fixes --report`
- **`mcts inventory` privacy controls** — `--paths-only`, `--config-path`, `--redact-paths`; documented in `SECURITY.md` (#87)

- **Attack graph v3 rollout (Phase 3a/3b)** — default `attack_graph_version=3`; YAML template matcher replaces `AttackChainAnalyzer`; 12 chain templates including `SSRF_RESOURCE`, `ENV_SAMPLING`, `GIT_UNSCOPED`, `PROMPT_BYPASS`, `ELICIT_PHISH`, `TOCTOU_READ`, `READ_EXEC`, `CRED_THEFT`; capability overlap fallbacks; dashboard v3 paths + SARIF `mcts/attackPathExplanation`; R-23–R-25 regression fixtures + `tests/scoring/test_phase_3b_templates.py`
- **Fact provenance metrics** — `fact_coverage()` reports `native_pct` / `silver_pct`; dashboard exposes `fact_provenance`; CI gates via `check_ttu_baseline.py` + corpus `--check-only`
Expand Down
21 changes: 21 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@ MCTS is a security analysis tool. Only scan MCP servers you own or have explicit

HTML reports are self-contained files with embedded scan data and vendored chart assets. They do not transmit data to MCTS or third parties when you open the file in a browser.

## Inventory Privacy

`mcts inventory` discovers MCP configuration files in well-known locations
(Cursor, Claude Desktop, VS Code, Windsurf, Gemini, Codex, and others). This is
a **read-only** operation — no data is sent externally, no files are modified,
and environment variable **values** are not exported (only key names when parsing
configs).

To limit exposure on shared or sensitive machines:

- `--paths-only` — list config file locations without parsing server details
- `--config-path <file>` — scope discovery to a single explicit file
- `--redact-paths` — replace home directory with `~` in inventory entry and
skill paths in terminal output and default inventory JSON (`-o inventory.json`)

`--redact-paths` does not redact paths inside nested scan reports produced by
`--scan-all`. Treat exported inventory JSON like any config audit artifact.

In CI environments, ephemeral runners typically have no MCP configs and inventory
will report zero entries.

## Documentation

- [Documentation index](docs/index.md)
Expand Down
3 changes: 3 additions & 0 deletions docs/platform/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ mcts inventory [options]
| `--skills` | false | Discover and scan `SKILL.md` files in agent skill directories |
| `--findings-trust-mode` | unset (`off`) | Apply trust validator to inventory/toxic-flow findings. Omit to inherit from `.mcts/policy.yaml`; pass explicitly (including `off`) to override policy. |
| `--ignore-policy` | off | Skip merging `.mcts/policy.yaml` |
| `--paths-only` | false | List config file paths without parsing server details |
| `--config-path` | — | Scope discovery to a single config file |
| `--redact-paths` | false | Replace home directory with `~` in inventory entry and skill paths |
| `--output`, `-o` | — | Write inventory JSON |
| `--theme` | `cyber` | Terminal theme |

Expand Down
17 changes: 17 additions & 0 deletions docs/scanning/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,22 @@ mcts inventory --full-toxic-flows

# Theme for saved-notice styling only
mcts inventory --theme minimal -o inventory.json

# Privacy controls (see SECURITY.md — Inventory Privacy)
mcts inventory --paths-only
mcts inventory --paths-only --redact-paths
mcts inventory --config-path ~/.cursor/mcp.json
mcts inventory --config-path ./team-mcp.json -o team.json --redact-paths
mcts inventory --config-path ~/.cursor/mcp.json --scan-all -o inventory-scan-all.json
```

`--paths-only` lists config locations without parsing server commands or env keys. It
cannot be combined with `--scan`, `--scan-all`, `--skills`, or `--output`.

`--config-path` inventories every server in one file (contrast with
`mcts scan --config <file> --server <name>`, which runs a full security scan on one
named server).

### Exit codes

| Code | When |
Expand Down Expand Up @@ -260,6 +274,9 @@ Full list: [Feature Expansion Plan — Discovery](../more/feature-expansion-plan

Inventory reads configuration files that may **reference** secrets via environment variables. MCTS does not print env values in inventory output. Treat exported JSON like any config audit artifact.

For privacy controls (`--paths-only`, `--config-path`, `--redact-paths`) and the
read-only inventory model, see [SECURITY.md — Inventory Privacy](../../SECURITY.md#inventory-privacy).

---

## Related
Expand Down
67 changes: 61 additions & 6 deletions src/mcts/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1280,13 +1280,31 @@ def inventory(
help="Skip merging .mcts/policy.yaml into inventory scans",
),
] = False,
redact_paths: Annotated[
bool,
typer.Option("--redact-paths", help="Replace home directory with ~ in output"),
] = False,
paths_only: Annotated[
bool,
typer.Option("--paths-only", help="List config file paths without parsing server details"),
] = False,
config_path_opt: Annotated[
Path | None,
typer.Option("--config-path", help="Scope to a single config file instead of auto-discovery"),
] = None,
) -> None:
"""Discover MCP servers configured across 12+ agent clients."""
from mcts.analyzers.cross_server import CrossServerAnalyzer
from mcts.analyzers.skill_md import analyze_skills
from mcts.analyzers.toxic_flows import analyze_inventory as analyze_toxic_flows
from mcts.core.config import ScanConfig
from mcts.governance import load_policy, merge_scan_config_with_policy
from mcts.inventory.discoverers import (
discover_config_paths,
redact_entry_dict,
redact_home,
redact_skill_dict,
)
from mcts.inventory.runner import enrich_with_tool_names, run_inventory
from mcts.inventory.scan_all import (
collect_scan_all_gate_violations,
Expand All @@ -1304,6 +1322,30 @@ def inventory(
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(code=2) from exc

if paths_only and (scan or scan_all or skills):
console.print(
"[red]Error:[/red] --paths-only cannot be combined with --scan, --scan-all, or --skills."
)
raise typer.Exit(code=2)
if paths_only and output is not None:
console.print("[red]Error:[/red] --paths-only does not support --output.")
raise typer.Exit(code=2)

if paths_only:
if config_path_opt is not None:
scoped = config_path_opt.expanduser().resolve()
rows = [("user", scoped)] if scoped.exists() else []
else:
rows = discover_config_paths()
if not rows:
console.print("[dim]No MCP config files found.[/dim]")
return
console.print(f"[bold]MCP config files[/bold] — {len(rows)} found")
for client, path in rows:
display = redact_home(str(path)) if redact_paths else str(path)
console.print(f" [{client}] {display}")
return

inv_config = merge_scan_config_with_policy(
ScanConfig(
target=Path("."),
Expand All @@ -1315,7 +1357,12 @@ def inventory(
)

if scan_all:
inventory_report, scan_rows = run_inventory_scan_all(inv_config)
inventory_report, scan_rows = run_inventory_scan_all(
inv_config,
config_path=config_path_opt,
skills=skills,
skills_dirs=skills_dir,
)
console.print(
f"[bold]Inventory scan-all[/bold] — {len(scan_rows)} server(s), "
f"{inventory_report.config_files_found} config file(s)"
Expand All @@ -1339,7 +1386,11 @@ def inventory(
raise typer.Exit(code=1)
return

report = run_inventory(skills=skills, skills_dirs=skills_dir)
report = run_inventory(
skills=skills,
skills_dirs=skills_dir,
config_path=config_path_opt,
)
entries = enrich_with_tool_names(report.entries) if scan else report.entries

shadow_findings = enrich_findings(CrossServerAnalyzer(entries).analyze_inventory(entries))
Expand All @@ -1357,12 +1408,14 @@ def inventory(
console.print(f" • {client}")
for entry in entries:
tools = f" ({len(entry.tools)} tools)" if entry.tools else ""
console.print(f" [{entry.client}] {entry.server_name}{tools} — {entry.config_path}")
display_path = redact_home(entry.config_path) if redact_paths else entry.config_path
console.print(f" [{entry.client}] {entry.server_name}{tools} — {display_path}")

if skills:
console.print(f"\n[bold]Skills[/bold] — {len(report.skills)} SKILL.md file(s)")
for skill in report.skills:
console.print(f" [{skill.client}] {skill.skill_name} — {skill.skill_path}")
skill_path = redact_home(skill.skill_path) if redact_paths else skill.skill_path
console.print(f" [{skill.client}] {skill.skill_name} — {skill_path}")
if skill_findings:
console.print(f"\n[yellow]Skill findings:[/yellow] {len(skill_findings)} issue(s)")
for finding in skill_findings[:5]:
Expand All @@ -1381,11 +1434,13 @@ def inventory(
payload = {
"clients_scanned": report.clients_scanned,
"config_files_found": report.config_files_found,
"entries": [entry.model_dump() for entry in entries],
"entries": [redact_entry_dict(entry.model_dump(), redact=redact_paths) for entry in entries],
"shadow_findings": [f.model_dump() for f in shadow_findings],
}
if skills:
payload["skills"] = [skill.model_dump() for skill in report.skills]
payload["skills"] = [
redact_skill_dict(skill.model_dump(), redact=redact_paths) for skill in report.skills
]
payload["skill_findings"] = [f.model_dump() for f in skill_findings]
if toxic_findings:
payload["toxic_flow_findings"] = [f.model_dump() for f in toxic_findings]
Expand Down
40 changes: 40 additions & 0 deletions src/mcts/inventory/discoverers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,46 @@
from mcts.inventory.models import InventoryEntry


def redact_home(path_str: str) -> str:
"""Replace the user's home directory prefix with ``~`` when paths resolve under it."""
if not path_str:
return path_str
try:
home = Path.home().resolve()
expanded = Path(path_str).expanduser()
resolved = expanded.resolve()
except (OSError, RuntimeError):
return path_str

home_text = str(home)
resolved_text = str(resolved)
if resolved_text == home_text:
return "~"
for sep in ("/", "\\"):
prefix = home_text + sep
if resolved_text.startswith(prefix):
return "~" + resolved_text[len(home_text) :].replace("\\", "/")
return path_str


def redact_entry_dict(data: dict, *, redact: bool) -> dict:
if not redact:
return data
out = dict(data)
if "config_path" in out:
out["config_path"] = redact_home(str(out["config_path"]))
return out


def redact_skill_dict(data: dict, *, redact: bool) -> dict:
if not redact:
return data
out = dict(data)
if "skill_path" in out:
out["skill_path"] = redact_home(str(out["skill_path"]))
return out


def discover_config_paths() -> list[tuple[str, Path]]:
rows: list[tuple[str, Path]] = []
seen: set[Path] = set()
Expand Down
23 changes: 20 additions & 3 deletions src/mcts/inventory/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,26 @@
from mcts.inventory.targets import resolve_entrypoint


def run_inventory(*, skills: bool = False, skills_dirs: list[Path] | None = None) -> InventoryReport:
def run_inventory(
*,
skills: bool = False,
skills_dirs: list[Path] | None = None,
config_path: Path | None = None,
) -> InventoryReport:
skill_entries = discover_skills(project_root=Path.cwd(), extra_dirs=skills_dirs) if skills else []

if config_path is not None:
path = config_path.expanduser().resolve()
if not path.exists():
return InventoryReport(skills=skill_entries)
entries = parse_config_file("user", path)
return InventoryReport(
entries=entries,
clients_scanned=["user"] if entries else [],
config_files_found=1,
skills=skill_entries,
)

entries: list[InventoryEntry] = []
clients: set[str] = set()
files_found = 0
Expand All @@ -20,8 +39,6 @@ def run_inventory(*, skills: bool = False, skills_dirs: list[Path] | None = None
clients.add(client)
entries.extend(parse_config_file(client, path))

skill_entries = discover_skills(project_root=Path.cwd(), extra_dirs=skills_dirs) if skills else []

return InventoryReport(
entries=entries,
clients_scanned=sorted(clients),
Expand Down
14 changes: 12 additions & 2 deletions src/mcts/inventory/scan_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,19 @@
from mcts.reporting.models import ScanReport


def run_inventory_scan_all(base_config: ScanConfig) -> tuple[InventoryReport, list[dict]]:
def run_inventory_scan_all(
base_config: ScanConfig,
*,
config_path: Path | None = None,
skills: bool = False,
skills_dirs: list[Path] | None = None,
) -> tuple[InventoryReport, list[dict]]:
"""Run a full security scan for each resolvable inventory entry."""
inventory = run_inventory()
inventory = run_inventory(
config_path=config_path,
skills=skills,
skills_dirs=skills_dirs,
)
rows: list[dict] = []
for entry in inventory.entries:
scan_config = entry_to_scan_config(entry, base_config)
Expand Down
81 changes: 81 additions & 0 deletions tests/test_cli_inventory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""CLI tests for mcts inventory privacy controls."""

from __future__ import annotations

import json
from pathlib import Path

import pytest
from typer.testing import CliRunner

from mcts.cli.main import app

runner = CliRunner()


def test_cli_paths_only_with_config_path(tmp_path: Path) -> None:
config = tmp_path / "team.json"
config.write_text(json.dumps({"mcpServers": {"hidden": {"command": "node"}}}))

result = runner.invoke(app, ["inventory", "--paths-only", "--config-path", str(config)])

assert result.exit_code == 0
assert "hidden" not in result.stdout
assert "team.json" in result.stdout


def test_cli_paths_only_conflicts_with_scan(tmp_path: Path) -> None:
config = tmp_path / "team.json"
config.write_text(json.dumps({"mcpServers": {"demo": {"command": "node"}}}))

result = runner.invoke(
app,
["inventory", "--paths-only", "--scan", "--config-path", str(config)],
)

assert result.exit_code == 2
assert "--paths-only cannot be combined" in result.stdout


def test_cli_paths_only_rejects_output(tmp_path: Path) -> None:
config = tmp_path / "team.json"
config.write_text(json.dumps({"mcpServers": {"demo": {"command": "node"}}}))
output = tmp_path / "out.json"

result = runner.invoke(
app,
["inventory", "--paths-only", "--config-path", str(config), "-o", str(output)],
)

assert result.exit_code == 2
assert "does not support --output" in result.stdout


def test_cli_redact_paths_json(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setenv("HOME", str(fake_home))
config = fake_home / ".cursor" / "mcp.json"
config.parent.mkdir(parents=True)
config.write_text(json.dumps({"mcpServers": {"myserver": {"command": "node", "args": ["server.js"]}}}))
output = tmp_path / "inventory.json"

result = runner.invoke(
app,
[
"inventory",
"--config-path",
str(config),
"--redact-paths",
"-o",
str(output),
"--theme",
"minimal",
],
)

assert result.exit_code == 0, result.stdout
payload = json.loads(output.read_text(encoding="utf-8"))
entry = payload["entries"][0]
assert entry["config_path"] == "~/.cursor/mcp.json"
assert "confing_path" not in entry
Loading
Loading