tessen-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tessen/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
tessen/cli.py ADDED
@@ -0,0 +1,206 @@
1
+ """Tessen CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from tessen import __version__
12
+ from tessen.output.human import render_findings, render_scan_summary
13
+ from tessen.output.json_output import render_json
14
+ from tessen.parsers.auto import auto_detect_and_parse
15
+ from tessen.parsers.common import ParseError
16
+ from tessen.parsers.claude_desktop import parse_claude_desktop
17
+ from tessen.parsers.claude_code import parse_claude_code
18
+ from tessen.parsers.cursor import parse_cursor
19
+ from tessen.rules.builtin import BUILTIN_RULES
20
+ from tessen.rules.engine import RuleEngine
21
+ from tessen.rules.models import Finding, Severity
22
+
23
+ app = typer.Typer(
24
+ name="tessen",
25
+ help="Security scanner for AI agent configurations.",
26
+ add_completion=False,
27
+ no_args_is_help=True,
28
+ )
29
+ console = Console()
30
+ error_console = Console(stderr=True)
31
+
32
+
33
+ CLIENT_LABELS = {
34
+ "claude_desktop": "Claude Desktop",
35
+ "claude_code": "Claude Code",
36
+ "cursor": "Cursor",
37
+ }
38
+
39
+
40
+ def _resolve_parser(path: Path, client: str | None):
41
+ if client is not None:
42
+ parsers = {
43
+ "claude_desktop": parse_claude_desktop,
44
+ "claude_code": parse_claude_code,
45
+ "cursor": parse_cursor,
46
+ }
47
+ if client not in parsers:
48
+ raise ParseError(
49
+ f"Unknown client: {client!r}. Valid options: {', '.join(parsers)}"
50
+ )
51
+ return parsers[client]
52
+
53
+ name = path.name
54
+ if name == ".mcp.json":
55
+ return parse_claude_code
56
+ if name == "mcp.json" and path.parent.name == ".cursor":
57
+ return parse_cursor
58
+ if name == "claude_desktop_config.json":
59
+ return parse_claude_desktop
60
+ return parse_claude_desktop
61
+
62
+
63
+ @app.command()
64
+ def scan(
65
+ path: Path | None = typer.Argument(
66
+ None,
67
+ help="Path to MCP config file. If omitted, auto-detects configs.",
68
+ resolve_path=True,
69
+ ),
70
+ client: str | None = typer.Option(
71
+ None,
72
+ "--client",
73
+ "-c",
74
+ help="Force a specific parser: claude_desktop, claude_code, or cursor.",
75
+ ),
76
+ output: str = typer.Option(
77
+ "human",
78
+ "--output",
79
+ "-o",
80
+ help="Output format: human (default) or json.",
81
+ ),
82
+ fail_on: str = typer.Option(
83
+ "high",
84
+ "--fail-on",
85
+ help="Exit non-zero if any finding at or above this severity. "
86
+ "One of: critical, high, medium, low, never.",
87
+ ),
88
+ ) -> None:
89
+ """Scan MCP configurations for vulnerabilities."""
90
+ if output not in ("human", "json"):
91
+ error_console.print(
92
+ f"[bold red]error:[/bold red] Unknown output format: {output!r}. "
93
+ f"Use 'human' or 'json'."
94
+ )
95
+ raise typer.Exit(code=2)
96
+
97
+ try:
98
+ if path is None:
99
+ if output == "human":
100
+ console.print(
101
+ "[bold cyan]tessen[/bold cyan] auto-detecting MCP configs..."
102
+ )
103
+ results = auto_detect_and_parse()
104
+ if not results:
105
+ if output == "human":
106
+ console.print(
107
+ "[yellow]No MCP configs found.[/yellow] "
108
+ "Try passing a path explicitly."
109
+ )
110
+ else:
111
+ print(render_json({}))
112
+ raise typer.Exit(code=0)
113
+ else:
114
+ if output == "human":
115
+ console.print(
116
+ f"[bold cyan]tessen[/bold cyan] scanning [dim]{path}[/dim]"
117
+ )
118
+ parser_fn = _resolve_parser(path, client)
119
+ results = [parser_fn(path)]
120
+ except ParseError as e:
121
+ error_console.print(f"[bold red]error:[/bold red] {e}")
122
+ raise typer.Exit(code=1)
123
+
124
+ engine = RuleEngine(BUILTIN_RULES)
125
+ all_findings: list[Finding] = []
126
+ findings_by_source: dict[str, list[Finding]] = {}
127
+
128
+ for result in results:
129
+ label = CLIENT_LABELS.get(result.client_type, result.client_type)
130
+ source = f"{label} ({result.source_path})"
131
+ findings = engine.run(result)
132
+ findings_by_source[source] = findings
133
+ all_findings.extend(findings)
134
+
135
+ if output == "json":
136
+ print(render_json(findings_by_source))
137
+ else:
138
+ for source, findings in findings_by_source.items():
139
+ render_findings(console, findings, source)
140
+ render_scan_summary(console, all_findings)
141
+
142
+ _exit_with_severity(all_findings, fail_on)
143
+
144
+
145
+ @app.command(name="rules")
146
+ def list_rules() -> None:
147
+ """List all available detection rules."""
148
+ table = Table(
149
+ title=f"{len(BUILTIN_RULES)} detection rules",
150
+ title_style="bold",
151
+ show_lines=False,
152
+ )
153
+ table.add_column("ID", style="cyan", no_wrap=True)
154
+ table.add_column("Severity", no_wrap=True)
155
+ table.add_column("Description")
156
+
157
+ severity_styles = {
158
+ Severity.CRITICAL: "bold red",
159
+ Severity.HIGH: "bold yellow",
160
+ Severity.MEDIUM: "cyan",
161
+ Severity.LOW: "dim",
162
+ }
163
+
164
+ for rule in BUILTIN_RULES:
165
+ meta = rule.METADATA
166
+ style = severity_styles.get(meta.severity, "")
167
+ table.add_row(
168
+ meta.id,
169
+ f"[{style}]{meta.severity.value}[/{style}]",
170
+ meta.description[:80] + ("..." if len(meta.description) > 80 else ""),
171
+ )
172
+
173
+ console.print(table)
174
+
175
+
176
+ def _exit_with_severity(findings: list[Finding], fail_on: str) -> None:
177
+ if fail_on == "never":
178
+ raise typer.Exit(code=0)
179
+
180
+ threshold_map = {
181
+ "critical": Severity.CRITICAL,
182
+ "high": Severity.HIGH,
183
+ "medium": Severity.MEDIUM,
184
+ "low": Severity.LOW,
185
+ }
186
+ if fail_on not in threshold_map:
187
+ error_console.print(
188
+ f"[bold red]error:[/bold red] Unknown --fail-on value: {fail_on!r}. "
189
+ f"Use one of: {', '.join(threshold_map)}, never."
190
+ )
191
+ raise typer.Exit(code=2)
192
+
193
+ threshold = threshold_map[fail_on]
194
+ if any(f.severity.rank >= threshold.rank for f in findings):
195
+ raise typer.Exit(code=1)
196
+ raise typer.Exit(code=0)
197
+
198
+
199
+ @app.command()
200
+ def version() -> None:
201
+ """Show Tessen version."""
202
+ console.print(f"tessen {__version__}")
203
+
204
+
205
+ if __name__ == "__main__":
206
+ app()
tessen/models.py ADDED
@@ -0,0 +1,57 @@
1
+ """Data models for MCP configurations.
2
+
3
+ These models represent the parsed shape of MCP config files.
4
+ Security-relevant fields are annotated with `# SECURITY:` comments.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ ClientType = Literal["claude_desktop", "claude_code", "cursor"]
15
+
16
+
17
+ class MCPServerEntry(BaseModel):
18
+ """A single MCP server definition within a client config."""
19
+
20
+ model_config = ConfigDict(extra="allow")
21
+
22
+ # SECURITY: command itself can be malicious (typosquatted packages).
23
+ command: str
24
+
25
+ # SECURITY: arg injection, attacker-controlled paths, dangerous flags.
26
+ args: list[str] = Field(default_factory=list)
27
+
28
+ # SECURITY: secrets in cleartext (API keys, tokens, passwords).
29
+ env: dict[str, str] = Field(default_factory=dict)
30
+
31
+ # SECURITY: HTTP transport without auth = remote access risk.
32
+ transport: Literal["stdio", "http", "sse"] | None = None
33
+
34
+ url: str | None = None
35
+
36
+
37
+ class MCPConfig(BaseModel):
38
+ """Generic MCP configuration shared across Claude Desktop, Claude Code, Cursor.
39
+
40
+ All three clients use the same `mcpServers` shape, so one model fits all.
41
+ """
42
+
43
+ model_config = ConfigDict(extra="allow")
44
+
45
+ mcpServers: dict[str, MCPServerEntry] = Field(default_factory=dict)
46
+
47
+
48
+ class ParseResult(BaseModel):
49
+ """The output of parsing a config file."""
50
+
51
+ source_path: Path
52
+ client_type: ClientType
53
+ config: MCPConfig
54
+
55
+ @property
56
+ def server_count(self) -> int:
57
+ return len(self.config.mcpServers)
File without changes
tessen/output/human.py ADDED
@@ -0,0 +1,98 @@
1
+ """Human-readable Rich output for findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+
9
+ from tessen.rules.models import Finding, Severity
10
+
11
+
12
+ SEVERITY_STYLES = {
13
+ Severity.CRITICAL: "bold red",
14
+ Severity.HIGH: "bold yellow",
15
+ Severity.MEDIUM: "cyan",
16
+ Severity.LOW: "dim",
17
+ }
18
+
19
+ SEVERITY_ICONS = {
20
+ Severity.CRITICAL: "●",
21
+ Severity.HIGH: "●",
22
+ Severity.MEDIUM: "○",
23
+ Severity.LOW: "○",
24
+ }
25
+
26
+
27
+ def render_findings(console: Console, findings: list[Finding], source: str) -> None:
28
+ """Render a list of findings for a single config file."""
29
+ if not findings:
30
+ console.print(f"[green]✓[/green] No findings for [dim]{source}[/dim]\n")
31
+ return
32
+
33
+ counts = _count_by_severity(findings)
34
+ header = _severity_summary(counts)
35
+ console.print(f"\n[bold]Findings for[/bold] [dim]{source}[/dim] {header}\n")
36
+
37
+ for i, finding in enumerate(findings, 1):
38
+ _render_finding(console, i, finding)
39
+
40
+
41
+ def render_scan_summary(console: Console, all_findings: list[Finding]) -> None:
42
+ """Render a summary across all scanned configs."""
43
+ if not all_findings:
44
+ console.print("\n[bold green]All configs clean — no findings.[/bold green]")
45
+ return
46
+
47
+ counts = _count_by_severity(all_findings)
48
+ console.print("\n[bold]Scan summary[/bold]")
49
+ console.print(f" Total findings: {len(all_findings)}")
50
+ for sev in [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW]:
51
+ if counts[sev] > 0:
52
+ style = SEVERITY_STYLES[sev]
53
+ console.print(f" [{style}]{sev.value.title():<9}[/{style}] {counts[sev]}")
54
+
55
+
56
+ def _render_finding(console: Console, index: int, finding: Finding) -> None:
57
+ style = SEVERITY_STYLES[finding.severity]
58
+ icon = SEVERITY_ICONS[finding.severity]
59
+
60
+ title = Text()
61
+ title.append(f"{icon} ", style=style)
62
+ title.append(f"[{finding.severity.value.upper()}] ", style=style)
63
+ title.append(finding.title, style="bold")
64
+
65
+ body = Text()
66
+ body.append(finding.message + "\n\n")
67
+ body.append("Location: ", style="dim")
68
+ body.append(finding.location + "\n", style="cyan")
69
+ body.append("Rule: ", style="dim")
70
+ body.append(finding.rule_id + "\n", style="magenta")
71
+ if finding.remediation:
72
+ body.append("Remediation: ", style="dim")
73
+ body.append(finding.remediation + "\n", style="green")
74
+
75
+ panel = Panel(
76
+ body,
77
+ title=title,
78
+ title_align="left",
79
+ border_style=style,
80
+ padding=(0, 1),
81
+ )
82
+ console.print(panel)
83
+
84
+
85
+ def _count_by_severity(findings: list[Finding]) -> dict[Severity, int]:
86
+ counts = {sev: 0 for sev in Severity}
87
+ for f in findings:
88
+ counts[f.severity] += 1
89
+ return counts
90
+
91
+
92
+ def _severity_summary(counts: dict[Severity, int]) -> str:
93
+ parts = []
94
+ for sev in [Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW]:
95
+ if counts[sev] > 0:
96
+ style = SEVERITY_STYLES[sev]
97
+ parts.append(f"[{style}]{counts[sev]} {sev.value}[/{style}]")
98
+ return " · ".join(parts)
@@ -0,0 +1,68 @@
1
+ """Machine-readable JSON output for findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from tessen import __version__
8
+ from tessen.rules.models import Finding
9
+
10
+
11
+ def render_json(
12
+ findings_by_source: dict[str, list[Finding]],
13
+ file=None,
14
+ ) -> str:
15
+ """Render all findings as a JSON document.
16
+
17
+ Returns the JSON string and optionally writes it to a file handle.
18
+ """
19
+ output = {
20
+ "version": __version__,
21
+ "scanner": "tessen",
22
+ "summary": {
23
+ "total": sum(len(f) for f in findings_by_source.values()),
24
+ "by_severity": _count_severities(findings_by_source),
25
+ "configs_scanned": len(findings_by_source),
26
+ },
27
+ "results": [
28
+ {
29
+ "source": source,
30
+ "findings": [_finding_to_dict(f) for f in findings],
31
+ }
32
+ for source, findings in findings_by_source.items()
33
+ ],
34
+ }
35
+
36
+ json_str = json.dumps(output, indent=2, default=str)
37
+
38
+ if file is not None:
39
+ file.write(json_str)
40
+
41
+ return json_str
42
+
43
+
44
+ def _finding_to_dict(finding: Finding) -> dict:
45
+ return {
46
+ "rule_id": finding.rule_id,
47
+ "severity": finding.severity.value,
48
+ "title": finding.title,
49
+ "message": finding.message,
50
+ "location": finding.location,
51
+ "remediation": finding.remediation,
52
+ "references": finding.references,
53
+ }
54
+
55
+
56
+ def _count_severities(
57
+ findings_by_source: dict[str, list[Finding]],
58
+ ) -> dict[str, int]:
59
+ counts: dict[str, int] = {
60
+ "critical": 0,
61
+ "high": 0,
62
+ "medium": 0,
63
+ "low": 0,
64
+ }
65
+ for findings in findings_by_source.values():
66
+ for f in findings:
67
+ counts[f.severity.value] += 1
68
+ return counts
File without changes
tessen/parsers/auto.py ADDED
@@ -0,0 +1,77 @@
1
+ """Auto-detect which MCP client config to scan.
2
+
3
+ Walks up from the current directory looking for project-scoped configs,
4
+ then falls back to user-scoped configs in standard locations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from tessen.parsers.claude_code import find_claude_code_config, parse_claude_code
13
+ from tessen.parsers.claude_desktop import parse_claude_desktop
14
+ from tessen.parsers.cursor import find_cursor_config, parse_cursor
15
+ from tessen.models import ParseResult
16
+
17
+
18
+ def find_claude_desktop_config() -> Path | None:
19
+ """Find the user-scoped Claude Desktop config based on the OS."""
20
+ home = Path.home()
21
+ if sys.platform == "darwin":
22
+ candidate = (
23
+ home
24
+ / "Library"
25
+ / "Application Support"
26
+ / "Claude"
27
+ / "claude_desktop_config.json"
28
+ )
29
+ elif sys.platform == "win32":
30
+ import os
31
+
32
+ appdata = os.environ.get("APPDATA")
33
+ if not appdata:
34
+ return None
35
+ candidate = Path(appdata) / "Claude" / "claude_desktop_config.json"
36
+ else: # linux and friends
37
+ candidate = home / ".config" / "Claude" / "claude_desktop_config.json"
38
+ return candidate if candidate.is_file() else None
39
+
40
+
41
+ def auto_detect_and_parse(start: Path | None = None) -> list[ParseResult]:
42
+ """Find all MCP configs reachable from `start` and parse them.
43
+
44
+ Searches in this order:
45
+ 1. Project-scoped Claude Code (.mcp.json, walking up from start)
46
+ 2. Project-scoped Cursor (.cursor/mcp.json, walking up from start)
47
+ 3. User-scoped Claude Desktop (OS-specific location)
48
+
49
+ Returns a list of ParseResults for everything found. Empty list if
50
+ nothing was found. Individual parse errors propagate (we don't silently
51
+ skip malformed configs).
52
+
53
+ Args:
54
+ start: Directory to begin the project-scoped search from.
55
+ Defaults to current working directory.
56
+ """
57
+ if start is None:
58
+ start = Path.cwd()
59
+
60
+ results: list[ParseResult] = []
61
+
62
+ claude_code_path = find_claude_code_config(start)
63
+ if claude_code_path is not None:
64
+ results.append(parse_claude_code(claude_code_path))
65
+
66
+ cursor_path = find_cursor_config(start)
67
+ if cursor_path is not None:
68
+ results.append(parse_cursor(cursor_path))
69
+
70
+ desktop_path = find_claude_desktop_config()
71
+ if desktop_path is not None:
72
+ results.append(parse_claude_desktop(desktop_path))
73
+
74
+ return results
75
+
76
+
77
+ __all__ = ["auto_detect_and_parse", "find_claude_desktop_config"]
@@ -0,0 +1,42 @@
1
+ """Parser for Claude Code MCP configurations.
2
+
3
+ Config locations:
4
+ - Project-scoped: <project_root>/.mcp.json
5
+ - User-scoped: ~/.claude.json (contains both user settings and MCP servers)
6
+
7
+ For V1 we focus on .mcp.json (project-scoped) — the most common case
8
+ for security audits.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from tessen.parsers.common import ParseError, parse_mcp_json_file
16
+ from tessen.models import ParseResult
17
+
18
+
19
+ PROJECT_CONFIG_NAME = ".mcp.json"
20
+
21
+
22
+ def parse_claude_code(path: Path) -> ParseResult:
23
+ """Parse a Claude Code .mcp.json file."""
24
+ return parse_mcp_json_file(path, client_type="claude_code")
25
+
26
+
27
+ def find_claude_code_config(start: Path) -> Path | None:
28
+ """Walk up from `start` looking for a .mcp.json file.
29
+
30
+ Returns the path if found, None otherwise. Used by auto-detection.
31
+ """
32
+ current = start.resolve()
33
+ while True:
34
+ candidate = current / PROJECT_CONFIG_NAME
35
+ if candidate.is_file():
36
+ return candidate
37
+ if current.parent == current: # reached filesystem root
38
+ return None
39
+ current = current.parent
40
+
41
+
42
+ __all__ = ["parse_claude_code", "find_claude_code_config", "ParseError"]
@@ -0,0 +1,22 @@
1
+ """Parser for Claude Desktop MCP configurations.
2
+
3
+ Config locations:
4
+ - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
5
+ - Linux: ~/.config/Claude/claude_desktop_config.json
6
+ - Windows: %APPDATA%\\Claude\\claude_desktop_config.json
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from tessen.parsers.common import ParseError, parse_mcp_json_file
14
+ from tessen.models import ParseResult
15
+
16
+
17
+ def parse_claude_desktop(path: Path) -> ParseResult:
18
+ """Parse a Claude Desktop config file."""
19
+ return parse_mcp_json_file(path, client_type="claude_desktop")
20
+
21
+
22
+ __all__ = ["parse_claude_desktop", "ParseError"]
@@ -0,0 +1,66 @@
1
+ """Shared parsing logic across MCP-format clients.
2
+
3
+ Claude Desktop, Claude Code, and Cursor all use the same JSON shape
4
+ under different filenames. This module holds the actual parsing work.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import get_args
12
+
13
+ from pydantic import ValidationError
14
+
15
+ from tessen.models import ClientType, MCPConfig, ParseResult
16
+
17
+
18
+ class ParseError(Exception):
19
+ """Raised when a config file cannot be parsed."""
20
+
21
+
22
+ def parse_mcp_json_file(path: Path, *, client_type: ClientType) -> ParseResult:
23
+ """Parse a JSON file using the standard MCP config schema.
24
+
25
+ Args:
26
+ path: Path to the config file.
27
+ client_type: Which client the file came from. Affects rules later.
28
+
29
+ Returns:
30
+ ParseResult containing the parsed config and metadata.
31
+
32
+ Raises:
33
+ ParseError: If the file doesn't exist, isn't valid JSON, or doesn't
34
+ match the expected schema.
35
+ """
36
+ if client_type not in get_args(ClientType):
37
+ raise ValueError(f"Unknown client_type: {client_type!r}")
38
+
39
+ if not path.exists():
40
+ raise ParseError(f"Config file not found: {path}")
41
+
42
+ if not path.is_file():
43
+ raise ParseError(f"Path is not a file: {path}")
44
+
45
+ try:
46
+ raw_text = path.read_text(encoding="utf-8")
47
+ except OSError as e:
48
+ raise ParseError(f"Could not read {path}: {e}") from e
49
+
50
+ try:
51
+ raw_json = json.loads(raw_text)
52
+ except json.JSONDecodeError as e:
53
+ raise ParseError(
54
+ f"Invalid JSON in {path} at line {e.lineno}, col {e.colno}: {e.msg}"
55
+ ) from e
56
+
57
+ try:
58
+ config = MCPConfig.model_validate(raw_json)
59
+ except ValidationError as e:
60
+ raise ParseError(f"Config schema validation failed for {path}:\n{e}") from e
61
+
62
+ return ParseResult(
63
+ source_path=path,
64
+ client_type=client_type,
65
+ config=config,
66
+ )
@@ -0,0 +1,38 @@
1
+ """Parser for Cursor MCP configurations.
2
+
3
+ Config locations:
4
+ - Project-scoped: <project_root>/.cursor/mcp.json
5
+ - Global: ~/.cursor/mcp.json
6
+
7
+ Cursor uses the same schema as Claude Desktop, just at a different path.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from tessen.parsers.common import ParseError, parse_mcp_json_file
15
+ from tessen.models import ParseResult
16
+
17
+
18
+ PROJECT_CONFIG_PATH = Path(".cursor") / "mcp.json"
19
+
20
+
21
+ def parse_cursor(path: Path) -> ParseResult:
22
+ """Parse a Cursor MCP config file."""
23
+ return parse_mcp_json_file(path, client_type="cursor")
24
+
25
+
26
+ def find_cursor_config(start: Path) -> Path | None:
27
+ """Walk up from `start` looking for a .cursor/mcp.json file."""
28
+ current = start.resolve()
29
+ while True:
30
+ candidate = current / PROJECT_CONFIG_PATH
31
+ if candidate.is_file():
32
+ return candidate
33
+ if current.parent == current:
34
+ return None
35
+ current = current.parent
36
+
37
+
38
+ __all__ = ["parse_cursor", "find_cursor_config", "ParseError"]
File without changes