CTFx 0.2.2__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.
ctfx/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CTFx package."""
2
+
3
+ __version__ = "0.2.2"
ctfx/cli.py ADDED
@@ -0,0 +1,81 @@
1
+ """Click entry point — registers all command groups and top-level aliases."""
2
+
3
+ import sys
4
+ import click
5
+
6
+ from ctfx import __version__
7
+ from ctfx.exceptions import CTFxError
8
+ from ctfx.commands.competition import comp_group, cmd_use, cmd_init
9
+ from ctfx.commands.challenge import chal_group, cmd_add
10
+ from ctfx.commands.terminal import cmd_cli, cmd_wsl, cmd_explorer, cmd_code, cmd_py
11
+ from ctfx.commands.platform import cmd_fetch, cmd_submit, cmd_import
12
+ from ctfx.commands.serve import cmd_serve
13
+ from ctfx.commands.ai import cmd_ai, cmd_mcp
14
+ from ctfx.commands.awd import awd_group
15
+ from ctfx.commands.setup import cmd_setup
16
+ from ctfx.commands.token import token_group
17
+ from ctfx.commands.webui import cmd_webui
18
+ from ctfx.commands.interactive import cmd_interactive
19
+ from ctfx.commands.config import config_group
20
+
21
+
22
+ @click.group(invoke_without_command=True)
23
+ @click.version_option(__version__, prog_name="ctfx")
24
+ @click.pass_context
25
+ def main(ctx: click.Context) -> None:
26
+ """ctfx — CTF workspace manager and assistant.
27
+
28
+ Local-first CTF workspace manager. Run 'ctfx setup' on first use.
29
+ Docs: https://github.com/liyanqwq/CTFx
30
+ """
31
+ if ctx.invoked_subcommand is None:
32
+ click.echo(ctx.get_help())
33
+
34
+
35
+ def _main() -> None:
36
+ """Wrapper that catches CTFxError for clean user-facing output."""
37
+ try:
38
+ main(standalone_mode=True)
39
+ except CTFxError as e:
40
+ click.secho(f"Error: {e}", fg="red", err=True)
41
+ sys.exit(1)
42
+
43
+
44
+ # Command groups
45
+ main.add_command(comp_group, name="comp")
46
+ main.add_command(chal_group, name="chal")
47
+ main.add_command(awd_group, name="awd")
48
+ main.add_command(token_group, name="token")
49
+ main.add_command(config_group, name="config")
50
+
51
+ # Direct commands
52
+ main.add_command(cmd_cli, name="cli")
53
+ main.add_command(cmd_wsl, name="wsl")
54
+ main.add_command(cmd_explorer, name="explorer")
55
+ main.add_command(cmd_explorer, name="e")
56
+ main.add_command(cmd_code, name="code")
57
+ main.add_command(cmd_py, name="py")
58
+ main.add_command(cmd_fetch, name="fetch")
59
+ main.add_command(cmd_submit, name="submit")
60
+ main.add_command(cmd_import, name="import")
61
+ main.add_command(cmd_serve, name="serve")
62
+ main.add_command(cmd_ai, name="ai")
63
+ main.add_command(cmd_mcp, name="mcp")
64
+ main.add_command(cmd_setup, name="setup")
65
+ main.add_command(cmd_webui, name="webui")
66
+ main.add_command(cmd_webui, name="web")
67
+ main.add_command(cmd_webui, name="ui")
68
+ main.add_command(cmd_interactive, name="interactive")
69
+ main.add_command(cmd_interactive, name="i")
70
+
71
+ # Top-level aliases
72
+ main.add_command(cmd_use, name="use")
73
+ main.add_command(cmd_init, name="init")
74
+ main.add_command(cmd_add, name="add")
75
+
76
+
77
+ @main.command("help")
78
+ @click.pass_context
79
+ def cmd_help(ctx: click.Context) -> None:
80
+ """Show this help message."""
81
+ click.echo(ctx.find_root().get_help())
@@ -0,0 +1 @@
1
+ """CLI command modules."""
ctfx/commands/ai.py ADDED
@@ -0,0 +1,228 @@
1
+ """AICommand — ctfx ai / ctfx mcp"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ import click
10
+ from rich.console import Console
11
+
12
+ from ctfx.managers.config import ConfigManager
13
+ from ctfx.managers.workspace import WorkspaceManager
14
+
15
+ console = Console()
16
+
17
+ # Marker for the user-editable section preserved across regenerations
18
+ _EXTRA_INFO_HEADER = "## Extra Info"
19
+ _EXTRA_INFO_DEFAULT = (
20
+ "<!-- preserved across regenerations; "
21
+ "add manual notes, known context, team strategy here -->"
22
+ )
23
+
24
+
25
+ def _load_active() -> tuple[ConfigManager, WorkspaceManager, dict]:
26
+ cfg = ConfigManager.load()
27
+ if not cfg.active_competition:
28
+ console.print("[red]No active competition.[/red] Run [cyan]ctfx use[/cyan] first.")
29
+ raise SystemExit(1)
30
+ wm = WorkspaceManager(cfg.basedir, cfg.active_competition)
31
+ try:
32
+ data = wm.load_ctf_json()
33
+ except FileNotFoundError:
34
+ console.print(f"[red]ctf.json not found for {cfg.active_competition}[/red]")
35
+ raise SystemExit(1)
36
+ return cfg, wm, data
37
+
38
+
39
+ def _extract_extra_info(existing_text: str) -> str:
40
+ """Extract the ## Extra Info section from an existing prompt.md, preserving user notes."""
41
+ match = re.search(
42
+ rf"^{re.escape(_EXTRA_INFO_HEADER)}\s*\n(.*)",
43
+ existing_text,
44
+ re.MULTILINE | re.DOTALL,
45
+ )
46
+ if match:
47
+ return match.group(1).rstrip()
48
+ return _EXTRA_INFO_DEFAULT
49
+
50
+
51
+ _MCP_SECTION = """\
52
+ ## MCP Tools
53
+
54
+ When connected via MCP (Cursor, Claude Desktop, or any MCP-capable client), use these tools:
55
+
56
+ | Tool | Description |
57
+ |------|-------------|
58
+ | `list_competitions` | List all competitions in the workspace |
59
+ | `get_competition` | Get active (or specified) competition metadata |
60
+ | `list_challenges` | List challenges — filter by `category` or `status` |
61
+ | `get_challenge` | Get full detail for a specific challenge by name |
62
+ | `add_challenge` | Create a new challenge directory with scaffold |
63
+ | `set_challenge_status` | Update status: `fetched` → `seen` → `working` → `solved` |
64
+ | `get_prompt` | Read this prompt.md file |
65
+ | `submit_flag` | Submit a flag to the platform (or record locally) |
66
+ | `run_exploit` | Run `solve/exploit.py` via the configured Python command |
67
+ | `list_awd_exploits` | List AWD exploit files for a service *(AWD mode only)* |
68
+ | `list_awd_patches` | List AWD patch files for a service *(AWD mode only)* |
69
+ | `get_config` | Read CTFx global configuration |
70
+ | `set_config` | Write a CTFx config value by dot-notation key |"""
71
+
72
+ _CLI_SECTION = """\
73
+ ## CLI Reference
74
+
75
+ Run these from your shell (prefix with `ctfx`):
76
+
77
+ ```
78
+ # Competition management
79
+ ctfx use <dir> Switch active competition
80
+ ctfx comp list List all competitions
81
+ ctfx comp init Create a new competition
82
+ ctfx comp info Show active competition metadata
83
+
84
+ # Challenge management
85
+ ctfx chal list [--cat CAT] [--status STATUS]
86
+ ctfx chal add <name> [cat] Add challenge (interactive category picker if cat omitted)
87
+ ctfx chal status <name> <status> [flag]
88
+ ctfx chal info <name>
89
+ ctfx chal rm <name>
90
+
91
+ # Platform integration
92
+ ctfx fetch [--cat CAT] Fetch challenges from CTFd
93
+ ctfx submit <flag> [--chal NAME] Submit flag to platform
94
+ ctfx import <url> LLM-assisted challenge import from URL
95
+
96
+ # Terminal helpers (path relative to competition root)
97
+ ctfx cli [path] Open terminal at path
98
+ ctfx wsl [path] Open WSL shell at path
99
+ ctfx code [path] Open VS Code at path
100
+ ctfx e [path] Open file explorer at path
101
+ ctfx py [file] Run Python (or open REPL)
102
+
103
+ # Server
104
+ ctfx serve [--port PORT] Start WebUI + API + MCP server
105
+ ctfx webui Open WebUI in browser (one-time login)
106
+ ctfx mcp [--out PATH] Generate MCP client config
107
+
108
+ # Other
109
+ ctfx ai [--print] Regenerate this prompt.md
110
+ ctfx token update Rotate root token
111
+ ctfx config show Show full config
112
+ ctfx config set <key> <value> Edit config by dot-notation key
113
+ ctfx i Interactive REPL (no ctfx prefix needed)
114
+ ```"""
115
+
116
+
117
+ def _build_prompt(data: dict, challenges: list[dict], extra_info: str) -> str:
118
+ name = data.get("name", "Unknown CTF")
119
+ year = data.get("year", "")
120
+ team_name = data.get("team_name", "")
121
+ flag_format = data.get("flag_format", "flag{...}")
122
+ mode = data.get("mode", "jeopardy")
123
+ platform = data.get("platform", "manual")
124
+ url = data.get("url", "")
125
+
126
+ lines = [
127
+ "You are a professional CTF (Capture The Flag) competition assistant with deep expertise",
128
+ "in all CTF categories including pwn, crypto, web, forensics, rev, and misc.",
129
+ "",
130
+ f"You are assisting team {team_name or '(unnamed)'} in the {name} {year} competition.",
131
+ "",
132
+ "## Competition Info",
133
+ ]
134
+ if url:
135
+ lines.append(f"- Platform: {url}")
136
+ lines += [
137
+ f"- Flag format: `{flag_format}`",
138
+ f"- Mode: {mode}",
139
+ f"- Platform adapter: {platform}",
140
+ "",
141
+ "Do not assume any challenge is a known or previously seen challenge. Treat every",
142
+ "challenge as original and reason from first principles.",
143
+ "",
144
+ "## Challenge Status",
145
+ ]
146
+
147
+ # Group challenges by category
148
+ by_cat: dict[str, list[dict]] = {}
149
+ for chal in challenges:
150
+ by_cat.setdefault(chal["category"] or "misc", []).append(chal)
151
+
152
+ for cat in sorted(by_cat):
153
+ lines.append(f"\n### {cat}")
154
+ for chal in by_cat[cat]:
155
+ pts = f" ({chal['points']} pts)" if chal.get("points") is not None else ""
156
+ status_marker = "✓" if chal["status"] == "solved" else "·"
157
+ lines.append(f"- {status_marker} `{chal['name']}` [{chal['status']}]{pts}")
158
+
159
+ lines += [
160
+ "",
161
+ _MCP_SECTION,
162
+ "",
163
+ _CLI_SECTION,
164
+ "",
165
+ _EXTRA_INFO_HEADER,
166
+ extra_info,
167
+ "",
168
+ ]
169
+ return "\n".join(lines)
170
+
171
+
172
+ @click.command("ai")
173
+ @click.option("--print", "do_print", is_flag=True, help="Also print prompt to stdout")
174
+ def cmd_ai(do_print: bool) -> None:
175
+ """Generate prompt.md with competition context for LLM use.
176
+
177
+ Preserves the '## Extra Info' section across regenerations.
178
+ """
179
+ cfg, wm, data = _load_active()
180
+ challenges = wm.list_challenges()
181
+
182
+ prompt_path = wm.competition_root() / "prompt.md"
183
+
184
+ # Preserve the ## Extra Info section if the file already exists
185
+ if prompt_path.exists():
186
+ existing = prompt_path.read_text(encoding="utf-8")
187
+ extra_info = _extract_extra_info(existing)
188
+ else:
189
+ extra_info = _EXTRA_INFO_DEFAULT
190
+
191
+ prompt = _build_prompt(data, challenges, extra_info)
192
+
193
+ if do_print:
194
+ console.print(prompt)
195
+ return
196
+
197
+ prompt_path.write_text(prompt, encoding="utf-8")
198
+ console.print(f"[green]Wrote[/green] {prompt_path}")
199
+ console.print(
200
+ f" [dim]{len(challenges)} challenges listed | "
201
+ f"edit '## Extra Info' to add manual context[/dim]"
202
+ )
203
+
204
+
205
+ @click.command("mcp")
206
+ @click.option("--out", default=None, help="Write MCP client config JSON to a file")
207
+ def cmd_mcp(out: str | None) -> None:
208
+ """Generate MCP client config for Claude Desktop / Cursor."""
209
+ cfg = ConfigManager.load()
210
+ payload = {
211
+ "mcpServers": {
212
+ "ctfx": {
213
+ "url": f"http://{cfg.serve_host}:{cfg.serve_port}/mcp/",
214
+ "headers": {
215
+ "Authorization": f"Bearer {cfg.root_token}",
216
+ },
217
+ }
218
+ }
219
+ }
220
+ text = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
221
+ if out:
222
+ Path(out).write_text(text, encoding="utf-8")
223
+ console.print(f"[green]Wrote[/green] {out}")
224
+ console.print(
225
+ f"[dim]MCP endpoint: http://{cfg.serve_host}:{cfg.serve_port}/mcp[/dim]"
226
+ )
227
+ else:
228
+ console.print(text)
ctfx/commands/awd.py ADDED
@@ -0,0 +1,214 @@
1
+ """AWDCommand — ctfx awd ssh / scp / cmd (AWD mode only)"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from ctfx.managers.config import ConfigManager
11
+ from ctfx.managers.workspace import WorkspaceManager
12
+
13
+ console = Console()
14
+
15
+
16
+ def _load_awd() -> tuple[ConfigManager, WorkspaceManager, dict]:
17
+ cfg = ConfigManager.load()
18
+ if not cfg.active_competition:
19
+ console.print("[red]No active competition.[/red] Run [cyan]ctfx use[/cyan] first.")
20
+ raise SystemExit(1)
21
+ wm = WorkspaceManager(cfg.basedir, cfg.active_competition)
22
+ try:
23
+ data = wm.load_ctf_json()
24
+ except FileNotFoundError:
25
+ console.print("[red]ctf.json not found.[/red]")
26
+ raise SystemExit(1)
27
+ if data.get("mode") != "awd":
28
+ console.print(
29
+ "[red]AWD commands are only available in AWD mode.[/red] "
30
+ "Set [cyan]\"mode\": \"awd\"[/cyan] in ctf.json."
31
+ )
32
+ raise SystemExit(1)
33
+ return cfg, wm, data
34
+
35
+
36
+ def _resolve_host(wm: WorkspaceManager, service: str, team: str | None) -> tuple[str, str]:
37
+ """Return (username, ip) for the target team/host."""
38
+ try:
39
+ hosts = wm.load_hostlist(service)
40
+ except ValueError as e:
41
+ console.print(f"[red]{e}[/red]")
42
+ raise SystemExit(1)
43
+ if not hosts:
44
+ console.print(
45
+ f"[red]No hosts found for service '{service}'.[/red] "
46
+ f"Create [cyan]{service}/hostlist.txt[/cyan] in the competition directory."
47
+ )
48
+ raise SystemExit(1)
49
+
50
+ if team:
51
+ match = next((h for h in hosts if h[0].lower() == team.lower()), None)
52
+ if not match:
53
+ available = ", ".join(h[0] for h in hosts)
54
+ console.print(
55
+ f"[red]Team '{team}' not found.[/red] Available: {available}"
56
+ )
57
+ raise SystemExit(1)
58
+ return "root", match[1]
59
+
60
+ if len(hosts) == 1:
61
+ return "root", hosts[0][1]
62
+
63
+ from rich.table import Table
64
+ table = Table(show_header=True, header_style="bold cyan", box=None)
65
+ table.add_column("#", width=3, style="dim")
66
+ table.add_column("Team")
67
+ table.add_column("IP")
68
+ for i, (t, ip) in enumerate(hosts, 1):
69
+ table.add_row(str(i), t, ip)
70
+ console.print(table)
71
+
72
+ while True:
73
+ raw = click.prompt("Select host number (or q to cancel)", default="q")
74
+ if raw.strip().lower() in ("q", "quit", ""):
75
+ raise SystemExit(0)
76
+ try:
77
+ idx = int(raw.strip()) - 1
78
+ if 0 <= idx < len(hosts):
79
+ return "root", hosts[idx][1]
80
+ except ValueError:
81
+ pass
82
+ console.print(f"[red]Invalid.[/red] Enter 1–{len(hosts)} or q.")
83
+
84
+
85
+ @click.group("awd")
86
+ def awd_group() -> None:
87
+ """AWD mode commands (SSH, SCP, remote exec)."""
88
+
89
+
90
+ @awd_group.command("ssh")
91
+ @click.argument("service")
92
+ @click.option("--team", default=None, help="Team name to SSH into")
93
+ @click.option("--port", default=22, type=int, show_default=True)
94
+ @click.option("--user", default="root", show_default=True)
95
+ def awd_ssh(service: str, team: str | None, port: int, user: str) -> None:
96
+ """Open an interactive SSH shell into a service host."""
97
+ _, wm, _ = _load_awd()
98
+ _, ip = _resolve_host(wm, service, team)
99
+ try:
100
+ key_path = wm.get_service_key_path(service)
101
+ except ValueError as e:
102
+ console.print(f"[red]{e}[/red]")
103
+ raise SystemExit(1)
104
+
105
+ console.print(
106
+ f"Connecting to [bold]{ip}:{port}[/bold] "
107
+ + (f"(key: {key_path.name})" if key_path else "(password auth)")
108
+ )
109
+
110
+ try:
111
+ from ctfx.managers.awd import AWDSession
112
+ with AWDSession(ip, port=port, username=user, key_path=key_path) as sess:
113
+ sess.interactive_shell()
114
+ except Exception as e:
115
+ console.print(f"[red]SSH failed:[/red] {e}")
116
+ raise SystemExit(1)
117
+
118
+
119
+ @awd_group.command("scp")
120
+ @click.argument("service")
121
+ @click.argument("src")
122
+ @click.argument("dst")
123
+ @click.option("--team", default=None, help="Target team name")
124
+ @click.option("--port", default=22, type=int, show_default=True)
125
+ @click.option("--user", default="root", show_default=True)
126
+ def awd_scp(service: str, src: str, dst: str, team: str | None, port: int, user: str) -> None:
127
+ """SCP file transfer. Prefix remote paths with ':' (e.g. :/tmp/file)."""
128
+ _, wm, _ = _load_awd()
129
+ _, ip = _resolve_host(wm, service, team)
130
+ try:
131
+ key_path = wm.get_service_key_path(service)
132
+ except ValueError as e:
133
+ console.print(f"[red]{e}[/red]")
134
+ raise SystemExit(1)
135
+
136
+ try:
137
+ from ctfx.managers.awd import AWDSession
138
+ with AWDSession(ip, port=port, username=user, key_path=key_path) as sess:
139
+ if src.startswith(":"):
140
+ remote = src[1:]
141
+ local = Path(dst)
142
+ console.print(f"[dim]Downloading[/dim] {ip}:{remote} → {local}")
143
+ sess.get(remote, local)
144
+ console.print(f"[green]Done.[/green] Saved to {local}")
145
+ elif dst.startswith(":"):
146
+ local = Path(src)
147
+ remote = dst[1:]
148
+ if not local.exists():
149
+ console.print(f"[red]Local file not found:[/red] {local}")
150
+ raise SystemExit(1)
151
+ console.print(f"[dim]Uploading[/dim] {local} → {ip}:{remote}")
152
+ sess.put(local, remote)
153
+ console.print(f"[green]Done.[/green]")
154
+ else:
155
+ console.print("[red]Prefix the remote path with ':' (e.g. :/tmp/file).[/red]")
156
+ raise SystemExit(1)
157
+ except SystemExit:
158
+ raise
159
+ except Exception as e:
160
+ console.print(f"[red]SCP failed:[/red] {e}")
161
+ raise SystemExit(1)
162
+
163
+
164
+ @awd_group.command("cmd")
165
+ @click.argument("service")
166
+ @click.argument("command", nargs=-1, required=True)
167
+ @click.option("--team", default=None, help="Run on a specific team's host")
168
+ @click.option("--all-teams", "all_teams", is_flag=True, help="Run on all hosts in hostlist")
169
+ @click.option("--port", default=22, type=int, show_default=True)
170
+ @click.option("--user", default="root", show_default=True)
171
+ @click.option("--timeout", "cmd_timeout", default=30.0, type=float, show_default=True)
172
+ def awd_cmd(
173
+ service: str,
174
+ command: tuple[str, ...],
175
+ team: str | None,
176
+ all_teams: bool,
177
+ port: int,
178
+ user: str,
179
+ cmd_timeout: float,
180
+ ) -> None:
181
+ """Run a remote command on service host(s) via SSH."""
182
+ _, wm, _ = _load_awd()
183
+ cmd_str = " ".join(command)
184
+ try:
185
+ key_path = wm.get_service_key_path(service)
186
+ except ValueError as e:
187
+ console.print(f"[red]{e}[/red]")
188
+ raise SystemExit(1)
189
+
190
+ if all_teams:
191
+ hosts = wm.load_hostlist(service)
192
+ if not hosts:
193
+ console.print(f"[red]No hosts found for service '{service}'.[/red]")
194
+ raise SystemExit(1)
195
+ targets = [(t, ip) for t, ip in hosts]
196
+ else:
197
+ _, ip = _resolve_host(wm, service, team)
198
+ targets = [(team or ip, ip)]
199
+
200
+ from ctfx.managers.awd import AWDSession
201
+
202
+ for team_name, ip in targets:
203
+ console.print(f"\n[bold cyan]{team_name}[/bold cyan] ({ip})")
204
+ try:
205
+ with AWDSession(ip, port=port, username=user, key_path=key_path) as sess:
206
+ stdout, stderr, code = sess.run(cmd_str, timeout=cmd_timeout)
207
+ if stdout.strip():
208
+ console.print(stdout.rstrip())
209
+ if stderr.strip():
210
+ console.print(f"[dim]{stderr.rstrip()}[/dim]")
211
+ if code != 0:
212
+ console.print(f"[yellow]exit {code}[/yellow]")
213
+ except Exception as e:
214
+ console.print(f" [red]Failed:[/red] {e}")