ctf-agent 1.2.0
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.
- package/AGENTS.md +131 -0
- package/Dockerfile +192 -0
- package/README.md +522 -0
- package/agents/ctf-analyzer.md +58 -0
- package/agents/ctf-controller.md +151 -0
- package/agents/ctf-speedrun.md +74 -0
- package/bin/ctf-agent.js +119 -0
- package/docker-compose.yml +13 -0
- package/mcp_config.json +19 -0
- package/package.json +44 -0
- package/references/ctf-triage-ladder.md +68 -0
- package/references/exploit-databases.md +575 -0
- package/references/llm-safety-and-policy-compliance.md +173 -0
- package/references/multi-agent-orchestration-and-policy-routing.md +278 -0
- package/references/security-events-and-intelligence.md +566 -0
- package/references/version-matrix.md +59 -0
- package/rules/ctf-execution-rules.md +44 -0
- package/rules/ctf-safety-framing-rules.md +140 -0
- package/rules/flag-validation-rules.md +25 -0
- package/scripts/__init__.py +1 -0
- package/scripts/ctf-tools.lock +205 -0
- package/scripts/ctf_init.py +1026 -0
- package/scripts/ctf_update.py +583 -0
- package/scripts/ctfd_client.py +81 -0
- package/scripts/cve_lookup.py +453 -0
- package/scripts/extract_flags.py +155 -0
- package/scripts/install_as_agent.py +278 -0
- package/scripts/install_ctf_tools.sh +603 -0
- package/scripts/parallel_triage.py +395 -0
- package/scripts/prompt_policy_sanitizer.py +283 -0
- package/scripts/scope_guard.py +444 -0
- package/scripts/skill_validator.py +504 -0
- package/scripts/workspace_cleaner.py +141 -0
- package/skills/ctf-ai-ml/SKILL.md +120 -0
- package/skills/ctf-ai-ml/adversarial-ml.md +623 -0
- package/skills/ctf-ai-ml/llm-attacks.md +487 -0
- package/skills/ctf-ai-ml/model-attacks.md +422 -0
- package/skills/ctf-crypto/SKILL.md +320 -0
- package/skills/ctf-crypto/advanced-math.md +798 -0
- package/skills/ctf-crypto/classic-ciphers.md +651 -0
- package/skills/ctf-crypto/ecc-attacks.md +347 -0
- package/skills/ctf-crypto/exotic-crypto-2.md +380 -0
- package/skills/ctf-crypto/exotic-crypto.md +528 -0
- package/skills/ctf-crypto/historical.md +113 -0
- package/skills/ctf-crypto/lattice-and-lwe.md +524 -0
- package/skills/ctf-crypto/modern-ciphers-2.md +563 -0
- package/skills/ctf-crypto/modern-ciphers-3.md +453 -0
- package/skills/ctf-crypto/modern-ciphers.md +649 -0
- package/skills/ctf-crypto/prng-attacks.md +257 -0
- package/skills/ctf-crypto/prng.md +664 -0
- package/skills/ctf-crypto/rsa-attacks-2.md +792 -0
- package/skills/ctf-crypto/rsa-attacks.md +487 -0
- package/skills/ctf-crypto/stream-ciphers.md +390 -0
- package/skills/ctf-crypto/zkp-and-advanced.md +456 -0
- package/skills/ctf-forensics/3d-printing.md +121 -0
- package/skills/ctf-forensics/SKILL.md +379 -0
- package/skills/ctf-forensics/disk-advanced.md +497 -0
- package/skills/ctf-forensics/disk-and-memory.md +491 -0
- package/skills/ctf-forensics/disk-recovery.md +699 -0
- package/skills/ctf-forensics/linux-forensics.md +511 -0
- package/skills/ctf-forensics/network-advanced.md +583 -0
- package/skills/ctf-forensics/network.md +645 -0
- package/skills/ctf-forensics/peripheral-capture.md +287 -0
- package/skills/ctf-forensics/signals-and-hardware.md +713 -0
- package/skills/ctf-forensics/steganography.md +694 -0
- package/skills/ctf-forensics/stego-advanced-2.md +475 -0
- package/skills/ctf-forensics/stego-advanced.md +481 -0
- package/skills/ctf-forensics/stego-image.md +691 -0
- package/skills/ctf-forensics/windows.md +625 -0
- package/skills/ctf-malware/SKILL.md +181 -0
- package/skills/ctf-malware/c2-and-protocols.md +274 -0
- package/skills/ctf-malware/pe-and-dotnet.md +108 -0
- package/skills/ctf-malware/scripts-and-obfuscation.md +449 -0
- package/skills/ctf-misc/SKILL.md +498 -0
- package/skills/ctf-misc/bashjails.md +323 -0
- package/skills/ctf-misc/ctfd-navigation.md +465 -0
- package/skills/ctf-misc/dns.md +255 -0
- package/skills/ctf-misc/encodings-advanced.md +504 -0
- package/skills/ctf-misc/encodings.md +431 -0
- package/skills/ctf-misc/games-and-vms-2.md +254 -0
- package/skills/ctf-misc/games-and-vms-3.md +690 -0
- package/skills/ctf-misc/games-and-vms-4.md +229 -0
- package/skills/ctf-misc/games-and-vms.md +529 -0
- package/skills/ctf-misc/linux-privesc.md +333 -0
- package/skills/ctf-misc/pyjails.md +671 -0
- package/skills/ctf-misc/rf-sdr.md +91 -0
- package/skills/ctf-osint/SKILL.md +198 -0
- package/skills/ctf-osint/geolocation-and-media.md +464 -0
- package/skills/ctf-osint/social-media.md +312 -0
- package/skills/ctf-osint/web-and-dns.md +341 -0
- package/skills/ctf-pwn/SKILL.md +214 -0
- package/skills/ctf-pwn/advanced-exploits-2.md +579 -0
- package/skills/ctf-pwn/advanced-exploits-3.md +598 -0
- package/skills/ctf-pwn/advanced-exploits-4.md +590 -0
- package/skills/ctf-pwn/advanced-exploits-5.md +119 -0
- package/skills/ctf-pwn/advanced-exploits.md +773 -0
- package/skills/ctf-pwn/advanced.md +326 -0
- package/skills/ctf-pwn/field-notes.md +245 -0
- package/skills/ctf-pwn/format-string.md +694 -0
- package/skills/ctf-pwn/heap-fsop.md +285 -0
- package/skills/ctf-pwn/heap-techniques-2.md +333 -0
- package/skills/ctf-pwn/heap-techniques.md +513 -0
- package/skills/ctf-pwn/kernel-bypass.md +421 -0
- package/skills/ctf-pwn/kernel-techniques.md +366 -0
- package/skills/ctf-pwn/kernel.md +636 -0
- package/skills/ctf-pwn/overflow-basics.md +611 -0
- package/skills/ctf-pwn/rop-advanced.md +725 -0
- package/skills/ctf-pwn/rop-and-shellcode.md +659 -0
- package/skills/ctf-pwn/sandbox-escape.md +313 -0
- package/skills/ctf-reverse/SKILL.md +163 -0
- package/skills/ctf-reverse/anti-analysis-ctf.md +204 -0
- package/skills/ctf-reverse/anti-analysis.md +693 -0
- package/skills/ctf-reverse/field-notes.md +376 -0
- package/skills/ctf-reverse/languages-compiled.md +666 -0
- package/skills/ctf-reverse/languages-platforms.md +592 -0
- package/skills/ctf-reverse/languages.md +553 -0
- package/skills/ctf-reverse/patterns-ctf-2.md +397 -0
- package/skills/ctf-reverse/patterns-ctf-3.md +797 -0
- package/skills/ctf-reverse/patterns-ctf.md +670 -0
- package/skills/ctf-reverse/patterns-runtime.md +274 -0
- package/skills/ctf-reverse/patterns.md +572 -0
- package/skills/ctf-reverse/platforms-hardware.md +387 -0
- package/skills/ctf-reverse/platforms.md +664 -0
- package/skills/ctf-reverse/tools-advanced-2.md +421 -0
- package/skills/ctf-reverse/tools-advanced.md +407 -0
- package/skills/ctf-reverse/tools-dynamic.md +679 -0
- package/skills/ctf-reverse/tools-emulation.md +319 -0
- package/skills/ctf-reverse/tools.md +573 -0
- package/skills/ctf-web/SKILL.md +153 -0
- package/skills/ctf-web/auth-and-access-2.md +82 -0
- package/skills/ctf-web/auth-and-access.md +783 -0
- package/skills/ctf-web/auth-infra.md +321 -0
- package/skills/ctf-web/auth-jwt.md +186 -0
- package/skills/ctf-web/client-side-advanced.md +739 -0
- package/skills/ctf-web/client-side.md +529 -0
- package/skills/ctf-web/cves.md +373 -0
- package/skills/ctf-web/field-notes.md +482 -0
- package/skills/ctf-web/node-and-prototype.md +200 -0
- package/skills/ctf-web/server-side-2.md +337 -0
- package/skills/ctf-web/server-side-advanced-2.md +559 -0
- package/skills/ctf-web/server-side-advanced-3.md +125 -0
- package/skills/ctf-web/server-side-advanced-4.md +480 -0
- package/skills/ctf-web/server-side-advanced.md +378 -0
- package/skills/ctf-web/server-side-deser.md +443 -0
- package/skills/ctf-web/server-side-exec-2.md +799 -0
- package/skills/ctf-web/server-side-exec.md +457 -0
- package/skills/ctf-web/server-side.md +629 -0
- package/skills/ctf-web/sql-injection.md +790 -0
- package/skills/ctf-web/web3.md +374 -0
- package/skills/ctf-writeup/SKILL.md +90 -0
- package/skills/solve-challenge/SKILL.md +269 -0
- package/skills.json +16 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CTF-Agent Workspace & Skill Update Engine
|
|
4
|
+
Synchronizes CTF-Agent skills, rules, agent personas, references, and scripts
|
|
5
|
+
into existing workspaces with SHA-256 diffing, lockfile verification, and conflict guards.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import shutil
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import argparse
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, Any, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
# Safe UTF-8 output on Windows consoles
|
|
18
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
19
|
+
try:
|
|
20
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
21
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
26
|
+
|
|
27
|
+
ESSENTIAL_DIRS = ["skills", "rules", "agents", "scripts", "references"]
|
|
28
|
+
ESSENTIAL_FILES = ["AGENTS.md", "mcp_config.json", "skills.json", "README.md"]
|
|
29
|
+
WORKSPACE_ROOT_FILES = ["AGENTS.md", "mcp_config.json", "skills.json"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SkillDiff:
|
|
33
|
+
"""Represents the diff state of a specific skill between upstream and workspace."""
|
|
34
|
+
|
|
35
|
+
STATUS_NEW = "NEW"
|
|
36
|
+
STATUS_UPDATED = "UPDATED"
|
|
37
|
+
STATUS_MODIFIED = "MODIFIED"
|
|
38
|
+
STATUS_CUSTOM = "CUSTOM"
|
|
39
|
+
STATUS_UP_TO_DATE = "UP_TO_DATE"
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
name: str,
|
|
44
|
+
status: str,
|
|
45
|
+
upstream_hash: Optional[str] = None,
|
|
46
|
+
local_hash: Optional[str] = None,
|
|
47
|
+
locked_hash: Optional[str] = None,
|
|
48
|
+
detail: str = "",
|
|
49
|
+
):
|
|
50
|
+
self.name = name
|
|
51
|
+
self.status = status
|
|
52
|
+
self.upstream_hash = upstream_hash
|
|
53
|
+
self.local_hash = local_hash
|
|
54
|
+
self.locked_hash = locked_hash
|
|
55
|
+
self.detail = detail
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"name": self.name,
|
|
60
|
+
"status": self.status,
|
|
61
|
+
"upstream_hash": self.upstream_hash,
|
|
62
|
+
"local_hash": self.local_hash,
|
|
63
|
+
"locked_hash": self.locked_hash,
|
|
64
|
+
"detail": self.detail,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WorkspaceUpdater:
|
|
69
|
+
"""Orchestrates workspace and skill synchronization with conflict protection."""
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def resolve_workspace(target_input: Optional[str]) -> Path:
|
|
73
|
+
"""Resolves target workspace directory from input string or current working directory."""
|
|
74
|
+
if target_input:
|
|
75
|
+
path = Path(target_input).resolve()
|
|
76
|
+
else:
|
|
77
|
+
path = Path.cwd().resolve()
|
|
78
|
+
|
|
79
|
+
if path.name in (".agents", ".agent") and path.parent.is_dir():
|
|
80
|
+
path = path.parent
|
|
81
|
+
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def compute_file_hash(path: Path) -> Optional[str]:
|
|
86
|
+
"""Calculates SHA-256 hash of a file if it exists."""
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def inspect_skills(cls, workspace_path: Path) -> List[SkillDiff]:
|
|
96
|
+
"""Compares upstream skills with workspace .agents/skills/ and skills-lock.json."""
|
|
97
|
+
diffs: List[SkillDiff] = []
|
|
98
|
+
|
|
99
|
+
dot_agents = workspace_path / ".agents"
|
|
100
|
+
if not dot_agents.exists() and (workspace_path / ".agent").exists():
|
|
101
|
+
dot_agents = workspace_path / ".agent"
|
|
102
|
+
|
|
103
|
+
local_skills_dir = dot_agents / "skills"
|
|
104
|
+
upstream_skills_dir = REPO_ROOT / "skills"
|
|
105
|
+
|
|
106
|
+
# Load skills-lock.json if available
|
|
107
|
+
lock_file = workspace_path / "skills-lock.json"
|
|
108
|
+
if not lock_file.exists():
|
|
109
|
+
lock_file = dot_agents / "skills-lock.json"
|
|
110
|
+
|
|
111
|
+
locked_skills: Dict[str, str] = {}
|
|
112
|
+
if lock_file.exists():
|
|
113
|
+
try:
|
|
114
|
+
data = json.loads(lock_file.read_text(encoding="utf-8"))
|
|
115
|
+
for s_name, s_meta in data.get("skills", {}).items():
|
|
116
|
+
if isinstance(s_meta, dict) and "computedHash" in s_meta:
|
|
117
|
+
locked_skills[s_name] = s_meta["computedHash"]
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
upstream_skill_names = set()
|
|
122
|
+
if upstream_skills_dir.exists():
|
|
123
|
+
for item in sorted(upstream_skills_dir.iterdir()):
|
|
124
|
+
if item.is_dir():
|
|
125
|
+
upstream_skill_names.add(item.name)
|
|
126
|
+
up_md = item / "SKILL.md"
|
|
127
|
+
up_hash = cls.compute_file_hash(up_md)
|
|
128
|
+
|
|
129
|
+
loc_dir = local_skills_dir / item.name
|
|
130
|
+
loc_md = loc_dir / "SKILL.md"
|
|
131
|
+
loc_hash = cls.compute_file_hash(loc_md)
|
|
132
|
+
lock_hash = locked_skills.get(item.name)
|
|
133
|
+
|
|
134
|
+
if not loc_dir.exists() or not loc_md.exists():
|
|
135
|
+
diffs.append(
|
|
136
|
+
SkillDiff(
|
|
137
|
+
name=item.name,
|
|
138
|
+
status=SkillDiff.STATUS_NEW,
|
|
139
|
+
upstream_hash=up_hash,
|
|
140
|
+
local_hash=None,
|
|
141
|
+
locked_hash=lock_hash,
|
|
142
|
+
detail="New skill available upstream",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
elif loc_hash == up_hash:
|
|
146
|
+
diffs.append(
|
|
147
|
+
SkillDiff(
|
|
148
|
+
name=item.name,
|
|
149
|
+
status=SkillDiff.STATUS_UP_TO_DATE,
|
|
150
|
+
upstream_hash=up_hash,
|
|
151
|
+
local_hash=loc_hash,
|
|
152
|
+
locked_hash=lock_hash,
|
|
153
|
+
detail="Up to date with upstream",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
if lock_hash and loc_hash == lock_hash:
|
|
158
|
+
diffs.append(
|
|
159
|
+
SkillDiff(
|
|
160
|
+
name=item.name,
|
|
161
|
+
status=SkillDiff.STATUS_UPDATED,
|
|
162
|
+
upstream_hash=up_hash,
|
|
163
|
+
local_hash=loc_hash,
|
|
164
|
+
locked_hash=lock_hash,
|
|
165
|
+
detail="Upstream update available (clean local copy)",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
elif lock_hash is None:
|
|
169
|
+
diffs.append(
|
|
170
|
+
SkillDiff(
|
|
171
|
+
name=item.name,
|
|
172
|
+
status=SkillDiff.STATUS_UPDATED,
|
|
173
|
+
upstream_hash=up_hash,
|
|
174
|
+
local_hash=loc_hash,
|
|
175
|
+
locked_hash=None,
|
|
176
|
+
detail="Upstream update available",
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
diffs.append(
|
|
181
|
+
SkillDiff(
|
|
182
|
+
name=item.name,
|
|
183
|
+
status=SkillDiff.STATUS_MODIFIED,
|
|
184
|
+
upstream_hash=up_hash,
|
|
185
|
+
local_hash=loc_hash,
|
|
186
|
+
locked_hash=lock_hash,
|
|
187
|
+
detail="Local modifications detected (different from lockfile)",
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Check for custom workspace skills not in upstream
|
|
192
|
+
if local_skills_dir.exists():
|
|
193
|
+
for loc_item in sorted(local_skills_dir.iterdir()):
|
|
194
|
+
if loc_item.is_dir() and loc_item.name not in upstream_skill_names:
|
|
195
|
+
loc_hash = cls.compute_file_hash(loc_item / "SKILL.md")
|
|
196
|
+
diffs.append(
|
|
197
|
+
SkillDiff(
|
|
198
|
+
name=loc_item.name,
|
|
199
|
+
status=SkillDiff.STATUS_CUSTOM,
|
|
200
|
+
upstream_hash=None,
|
|
201
|
+
local_hash=loc_hash,
|
|
202
|
+
locked_hash=locked_skills.get(loc_item.name),
|
|
203
|
+
detail="Custom workspace skill (preserved untouched)",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return diffs
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def sync_directory_with_guard(
|
|
211
|
+
cls,
|
|
212
|
+
src_dir: Path,
|
|
213
|
+
dest_dir: Path,
|
|
214
|
+
label: str,
|
|
215
|
+
force: bool = False,
|
|
216
|
+
dry_run: bool = False,
|
|
217
|
+
) -> List[str]:
|
|
218
|
+
"""
|
|
219
|
+
Surgically synchronizes a directory file-by-file with SHA-256 diffing.
|
|
220
|
+
- Installs new files from upstream
|
|
221
|
+
- Updates modified files while creating <file>.bak backups
|
|
222
|
+
- Preserves user custom files completely untouched
|
|
223
|
+
"""
|
|
224
|
+
actions: List[str] = []
|
|
225
|
+
if not src_dir.exists():
|
|
226
|
+
return actions
|
|
227
|
+
|
|
228
|
+
if not dry_run:
|
|
229
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
upstream_rel_paths = set()
|
|
231
|
+
|
|
232
|
+
# 1. Inspect upstream files
|
|
233
|
+
for src_file in src_dir.rglob("*"):
|
|
234
|
+
if src_file.is_file():
|
|
235
|
+
if src_file.suffix == ".pyc" or "__pycache__" in src_file.parts:
|
|
236
|
+
continue
|
|
237
|
+
rel = src_file.relative_to(src_dir)
|
|
238
|
+
upstream_rel_paths.add(rel)
|
|
239
|
+
target_file = dest_dir / rel
|
|
240
|
+
|
|
241
|
+
if not target_file.exists():
|
|
242
|
+
if not dry_run:
|
|
243
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
shutil.copy2(src_file, target_file)
|
|
245
|
+
actions.append(f"{'Would install' if dry_run else 'Installed'} new {label}: {rel}")
|
|
246
|
+
else:
|
|
247
|
+
up_hash = cls.compute_file_hash(src_file)
|
|
248
|
+
loc_hash = cls.compute_file_hash(target_file)
|
|
249
|
+
if up_hash != loc_hash:
|
|
250
|
+
if not dry_run:
|
|
251
|
+
bak_file = target_file.with_name(target_file.name + ".bak")
|
|
252
|
+
shutil.copy2(target_file, bak_file)
|
|
253
|
+
shutil.copy2(src_file, target_file)
|
|
254
|
+
actions.append(f"{'Would update' if dry_run else 'Updated'} {label}{' with backup' if not dry_run else ''}: {rel}{' (saved .bak)' if not dry_run else ''}")
|
|
255
|
+
|
|
256
|
+
# 2. Inspect local directory for custom files
|
|
257
|
+
if dest_dir.exists():
|
|
258
|
+
for loc_file in dest_dir.rglob("*"):
|
|
259
|
+
if loc_file.is_file():
|
|
260
|
+
if loc_file.suffix == ".pyc" or "__pycache__" in loc_file.parts:
|
|
261
|
+
continue
|
|
262
|
+
rel = loc_file.relative_to(dest_dir)
|
|
263
|
+
if not str(rel).endswith(".bak") and rel not in upstream_rel_paths:
|
|
264
|
+
actions.append(f"Preserved custom {label}: {rel}")
|
|
265
|
+
|
|
266
|
+
return actions
|
|
267
|
+
|
|
268
|
+
@classmethod
|
|
269
|
+
def update_workspace(
|
|
270
|
+
cls,
|
|
271
|
+
workspace_path: Path,
|
|
272
|
+
skills_only: bool = False,
|
|
273
|
+
dry_run: bool = False,
|
|
274
|
+
force: bool = False,
|
|
275
|
+
) -> Dict[str, Any]:
|
|
276
|
+
"""Executes synchronization of skills and support files into target workspace."""
|
|
277
|
+
dot_agents = workspace_path / ".agents"
|
|
278
|
+
if not dot_agents.exists() and (workspace_path / ".agent").exists():
|
|
279
|
+
dot_agents = workspace_path / ".agent"
|
|
280
|
+
|
|
281
|
+
if not dot_agents.exists():
|
|
282
|
+
raise FileNotFoundError(
|
|
283
|
+
f"No .agents/ or .agent/ directory found in {workspace_path}. "
|
|
284
|
+
f"Please run 'ctf-agent init' first to initialize the workspace."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
diffs = cls.inspect_skills(workspace_path)
|
|
288
|
+
new_skills = [d for d in diffs if d.status == SkillDiff.STATUS_NEW]
|
|
289
|
+
updated_skills = [d for d in diffs if d.status == SkillDiff.STATUS_UPDATED]
|
|
290
|
+
modified_skills = [d for d in diffs if d.status == SkillDiff.STATUS_MODIFIED]
|
|
291
|
+
custom_skills = [d for d in diffs if d.status == SkillDiff.STATUS_CUSTOM]
|
|
292
|
+
uptodate_skills = [d for d in diffs if d.status == SkillDiff.STATUS_UP_TO_DATE]
|
|
293
|
+
|
|
294
|
+
result: Dict[str, Any] = {
|
|
295
|
+
"workspace": str(workspace_path.resolve()),
|
|
296
|
+
"dot_agents": str(dot_agents.resolve()),
|
|
297
|
+
"dry_run": dry_run,
|
|
298
|
+
"skills_only": skills_only,
|
|
299
|
+
"skills_summary": {
|
|
300
|
+
"new": len(new_skills),
|
|
301
|
+
"updated": len(updated_skills),
|
|
302
|
+
"modified": len(modified_skills),
|
|
303
|
+
"custom": len(custom_skills),
|
|
304
|
+
"up_to_date": len(uptodate_skills),
|
|
305
|
+
},
|
|
306
|
+
"skills_detail": [d.to_dict() for d in diffs],
|
|
307
|
+
"actions_taken": [],
|
|
308
|
+
"planned_actions": [],
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
action_sink = result["planned_actions"] if dry_run else result["actions_taken"]
|
|
312
|
+
|
|
313
|
+
local_skills_dir = dot_agents / "skills"
|
|
314
|
+
if not dry_run:
|
|
315
|
+
local_skills_dir.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
upstream_skills_dir = REPO_ROOT / "skills"
|
|
317
|
+
|
|
318
|
+
# 1. Synchronize Skills
|
|
319
|
+
for diff in diffs:
|
|
320
|
+
if diff.status in (SkillDiff.STATUS_NEW, SkillDiff.STATUS_UPDATED):
|
|
321
|
+
if not dry_run:
|
|
322
|
+
src_skill = upstream_skills_dir / diff.name
|
|
323
|
+
dest_skill = local_skills_dir / diff.name
|
|
324
|
+
if dest_skill.exists():
|
|
325
|
+
shutil.rmtree(dest_skill)
|
|
326
|
+
shutil.copytree(src_skill, dest_skill)
|
|
327
|
+
action_str = f"{'Would install' if dry_run else 'Installed'} new skill: {diff.name}" if diff.status == SkillDiff.STATUS_NEW else f"{'Would update' if dry_run else 'Updated'} skill: {diff.name}"
|
|
328
|
+
action_sink.append(action_str)
|
|
329
|
+
|
|
330
|
+
elif diff.status == SkillDiff.STATUS_MODIFIED:
|
|
331
|
+
dest_skill = local_skills_dir / diff.name
|
|
332
|
+
src_skill = upstream_skills_dir / diff.name
|
|
333
|
+
if not dry_run:
|
|
334
|
+
# Backup local modified SKILL.md before overwrite
|
|
335
|
+
local_md = dest_skill / "SKILL.md"
|
|
336
|
+
local_bytes = local_md.read_bytes() if local_md.exists() else None
|
|
337
|
+
if dest_skill.exists():
|
|
338
|
+
shutil.rmtree(dest_skill)
|
|
339
|
+
shutil.copytree(src_skill, dest_skill)
|
|
340
|
+
# Re-place backup for user inspection
|
|
341
|
+
if local_bytes is not None:
|
|
342
|
+
(dest_skill / "SKILL.md.bak").write_bytes(local_bytes)
|
|
343
|
+
action_str = f"{'Would update' if dry_run else 'Updated'} modified skill with backup: {diff.name}{' (saved SKILL.md.bak)' if not dry_run else ''}"
|
|
344
|
+
action_sink.append(action_str)
|
|
345
|
+
|
|
346
|
+
elif diff.status == SkillDiff.STATUS_CUSTOM:
|
|
347
|
+
custom_skill_dir = local_skills_dir / diff.name
|
|
348
|
+
audit_suffix = ""
|
|
349
|
+
try:
|
|
350
|
+
from scripts.skill_validator import SkillEnvelopeValidator
|
|
351
|
+
val_rep = SkillEnvelopeValidator.validate_skill_dir(custom_skill_dir)
|
|
352
|
+
if not val_rep.is_valid:
|
|
353
|
+
audit_suffix = f" (envelope issues: {len(val_rep.errors)} errors, run 'ctf-agent validate-skill {custom_skill_dir}')"
|
|
354
|
+
elif val_rep.warnings:
|
|
355
|
+
audit_suffix = f" (envelope warnings: {len(val_rep.warnings)})"
|
|
356
|
+
else:
|
|
357
|
+
audit_suffix = " (envelope verified PASS)"
|
|
358
|
+
except Exception:
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
action_sink.append(f"Preserved custom skill: {diff.name}{audit_suffix}")
|
|
362
|
+
|
|
363
|
+
# 2. Synchronize non-skill directories & files unless skills_only
|
|
364
|
+
if not skills_only:
|
|
365
|
+
for dir_name in ["rules", "agents", "references", "scripts"]:
|
|
366
|
+
src_d = REPO_ROOT / dir_name
|
|
367
|
+
dest_d = dot_agents / dir_name
|
|
368
|
+
dir_actions = cls.sync_directory_with_guard(src_d, dest_d, label=f".agents/{dir_name}", force=force, dry_run=dry_run)
|
|
369
|
+
action_sink.extend(dir_actions)
|
|
370
|
+
|
|
371
|
+
# Essential files in .agents/
|
|
372
|
+
for f_name in ESSENTIAL_FILES:
|
|
373
|
+
src_file = REPO_ROOT / f_name
|
|
374
|
+
dest_file = dot_agents / f_name
|
|
375
|
+
if src_file.exists():
|
|
376
|
+
up_h = cls.compute_file_hash(src_file)
|
|
377
|
+
loc_h = cls.compute_file_hash(dest_file)
|
|
378
|
+
if not dest_file.exists():
|
|
379
|
+
if not dry_run:
|
|
380
|
+
shutil.copy2(src_file, dest_file)
|
|
381
|
+
action_sink.append(f"{'Would install' if dry_run else 'Installed'} .agents/{f_name}")
|
|
382
|
+
elif up_h != loc_h:
|
|
383
|
+
if not dry_run:
|
|
384
|
+
shutil.copy2(dest_file, dest_file.with_name(f_name + ".bak"))
|
|
385
|
+
shutil.copy2(src_file, dest_file)
|
|
386
|
+
action_sink.append(f"{'Would update' if dry_run else 'Updated'} .agents/{f_name}{' (saved .bak)' if not dry_run else ''}")
|
|
387
|
+
|
|
388
|
+
# Workspace root files (AGENTS.md, mcp_config.json, skills.json)
|
|
389
|
+
for f_name in WORKSPACE_ROOT_FILES:
|
|
390
|
+
src_file = REPO_ROOT / f_name
|
|
391
|
+
dest_file = workspace_path / f_name
|
|
392
|
+
if src_file.exists():
|
|
393
|
+
if f_name == "AGENTS.md" and workspace_path != REPO_ROOT:
|
|
394
|
+
content = src_file.read_text(encoding="utf-8")
|
|
395
|
+
content = content.replace("](references/", "](.agents/references/")
|
|
396
|
+
content = content.replace("](rules/", "](.agents/rules/")
|
|
397
|
+
dest_content = dest_file.read_text(encoding="utf-8") if dest_file.exists() else None
|
|
398
|
+
if dest_content != content:
|
|
399
|
+
if not dry_run:
|
|
400
|
+
dest_file.write_text(content, encoding="utf-8", newline="\n")
|
|
401
|
+
action_sink.append(f"{'Would update' if dry_run else 'Updated'} workspace root: {f_name}")
|
|
402
|
+
else:
|
|
403
|
+
up_h = cls.compute_file_hash(src_file)
|
|
404
|
+
loc_h = cls.compute_file_hash(dest_file)
|
|
405
|
+
if up_h != loc_h:
|
|
406
|
+
if not dry_run:
|
|
407
|
+
shutil.copy2(src_file, dest_file)
|
|
408
|
+
action_sink.append(f"{'Would update' if dry_run else 'Updated'} workspace root: {f_name}")
|
|
409
|
+
|
|
410
|
+
# Workspace scripts/ synchronization if folder exists
|
|
411
|
+
ws_scripts = workspace_path / "scripts"
|
|
412
|
+
if ws_scripts.is_dir():
|
|
413
|
+
src_scripts = REPO_ROOT / "scripts"
|
|
414
|
+
ws_actions = cls.sync_directory_with_guard(src_scripts, ws_scripts, label="scripts", force=force, dry_run=dry_run)
|
|
415
|
+
action_sink.extend(ws_actions)
|
|
416
|
+
|
|
417
|
+
# 3. Regenerate skills-lock.json with updated SHA-256 hashes
|
|
418
|
+
skills_lock: Dict[str, Any] = {"version": 1, "skills": {}}
|
|
419
|
+
if local_skills_dir.exists():
|
|
420
|
+
for s_dir in sorted(local_skills_dir.iterdir()):
|
|
421
|
+
if s_dir.is_dir():
|
|
422
|
+
s_md = s_dir / "SKILL.md"
|
|
423
|
+
if s_md.exists():
|
|
424
|
+
h = cls.compute_file_hash(s_md)
|
|
425
|
+
skills_lock["skills"][s_dir.name] = {
|
|
426
|
+
"source": "CTF-Agent (local)",
|
|
427
|
+
"sourceType": "local",
|
|
428
|
+
"skillPath": f".agents/skills/{s_dir.name}/SKILL.md",
|
|
429
|
+
"computedHash": h or "",
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
lock_target = workspace_path / "skills-lock.json"
|
|
433
|
+
lock_content = json.dumps(skills_lock, indent=2) + "\n"
|
|
434
|
+
existing_lock = lock_target.read_text(encoding="utf-8") if lock_target.exists() else None
|
|
435
|
+
needs_lock_update = (existing_lock is None) or (existing_lock.strip() != lock_content.strip())
|
|
436
|
+
if needs_lock_update:
|
|
437
|
+
if not dry_run:
|
|
438
|
+
lock_target.write_text(lock_content, encoding="utf-8", newline="\n")
|
|
439
|
+
action_sink.append(f"Regenerated {lock_target.name}")
|
|
440
|
+
else:
|
|
441
|
+
action_sink.append(f"Would regenerate {lock_target.name}")
|
|
442
|
+
|
|
443
|
+
return result
|
|
444
|
+
|
|
445
|
+
@classmethod
|
|
446
|
+
def update_globally(cls, dry_run: bool = False, force: bool = False) -> Dict[str, Any]:
|
|
447
|
+
"""Updates global Antigravity configuration in ~/.gemini/config/."""
|
|
448
|
+
global_config = Path.home() / ".gemini" / "config"
|
|
449
|
+
result: Dict[str, Any] = {
|
|
450
|
+
"global_config": str(global_config.resolve()),
|
|
451
|
+
"dry_run": dry_run,
|
|
452
|
+
"actions_taken": [],
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if dry_run:
|
|
456
|
+
return result
|
|
457
|
+
|
|
458
|
+
global_config.mkdir(parents=True, exist_ok=True)
|
|
459
|
+
|
|
460
|
+
# 1. Update Global Skills
|
|
461
|
+
global_skills = global_config / "skills"
|
|
462
|
+
global_skills.mkdir(exist_ok=True)
|
|
463
|
+
for s_path in (REPO_ROOT / "skills").iterdir():
|
|
464
|
+
if s_path.is_dir():
|
|
465
|
+
dest = global_skills / s_path.name
|
|
466
|
+
if dest.exists() and force:
|
|
467
|
+
shutil.rmtree(dest)
|
|
468
|
+
if not dest.exists():
|
|
469
|
+
shutil.copytree(s_path, dest)
|
|
470
|
+
result["actions_taken"].append(f"Installed global skill: {s_path.name}")
|
|
471
|
+
elif dest.exists():
|
|
472
|
+
shutil.rmtree(dest)
|
|
473
|
+
shutil.copytree(s_path, dest)
|
|
474
|
+
result["actions_taken"].append(f"Updated global skill: {s_path.name}")
|
|
475
|
+
|
|
476
|
+
# 2. Update Global Agents
|
|
477
|
+
global_agents = global_config / "agents"
|
|
478
|
+
global_agents.mkdir(exist_ok=True)
|
|
479
|
+
for a_path in (REPO_ROOT / "agents").iterdir():
|
|
480
|
+
if a_path.is_file() and a_path.suffix == ".md":
|
|
481
|
+
dest = global_agents / a_path.name
|
|
482
|
+
shutil.copy2(a_path, dest)
|
|
483
|
+
result["actions_taken"].append(f"Updated global agent: {a_path.name}")
|
|
484
|
+
|
|
485
|
+
# 3. Update Global Rules
|
|
486
|
+
global_rules = global_config / "rules"
|
|
487
|
+
global_rules.mkdir(exist_ok=True)
|
|
488
|
+
for r_path in (REPO_ROOT / "rules").iterdir():
|
|
489
|
+
if r_path.is_file() and r_path.suffix == ".md":
|
|
490
|
+
dest = global_rules / r_path.name
|
|
491
|
+
shutil.copy2(r_path, dest)
|
|
492
|
+
result["actions_taken"].append(f"Updated global rule: {r_path.name}")
|
|
493
|
+
|
|
494
|
+
return result
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def print_update_report(result: Dict[str, Any]):
|
|
498
|
+
"""Displays user-friendly terminal update report."""
|
|
499
|
+
print("\n=================================================================")
|
|
500
|
+
print("CTF-AGENT WORKSPACE & SKILL UPDATE REPORT")
|
|
501
|
+
print("=================================================================")
|
|
502
|
+
print(f"Target Workspace: {result.get('workspace', result.get('global_config'))}")
|
|
503
|
+
if result.get("dry_run"):
|
|
504
|
+
print("Mode : DRY RUN (No changes written)")
|
|
505
|
+
else:
|
|
506
|
+
print("Mode : LIVE SYNCHRONIZATION")
|
|
507
|
+
print("-----------------------------------------------------------------")
|
|
508
|
+
|
|
509
|
+
summary = result.get("skills_summary", {})
|
|
510
|
+
if summary:
|
|
511
|
+
print(f"Skills Overview:")
|
|
512
|
+
print(f" [+] New available : {summary.get('new', 0)}")
|
|
513
|
+
print(f" [*] Updated : {summary.get('updated', 0)}")
|
|
514
|
+
print(f" [!] Locally modified : {summary.get('modified', 0)}")
|
|
515
|
+
print(f" [=] Up to date : {summary.get('up_to_date', 0)}")
|
|
516
|
+
print(f" [#] Custom (preserved): {summary.get('custom', 0)}")
|
|
517
|
+
print("-----------------------------------------------------------------")
|
|
518
|
+
|
|
519
|
+
actions = result.get("actions_taken", [])
|
|
520
|
+
planned = result.get("planned_actions", [])
|
|
521
|
+
if actions:
|
|
522
|
+
print("Actions Executed:")
|
|
523
|
+
for act in actions:
|
|
524
|
+
print(f" [+] {act}")
|
|
525
|
+
print("-----------------------------------------------------------------")
|
|
526
|
+
elif result.get("dry_run"):
|
|
527
|
+
print("Planned Changes:")
|
|
528
|
+
for s in result.get("skills_detail", []):
|
|
529
|
+
st = s["status"]
|
|
530
|
+
name = s["name"]
|
|
531
|
+
detail = s["detail"]
|
|
532
|
+
if st == SkillDiff.STATUS_NEW:
|
|
533
|
+
print(f" [+] {name:30} -> {detail}")
|
|
534
|
+
elif st == SkillDiff.STATUS_UPDATED:
|
|
535
|
+
print(f" [*] {name:30} -> {detail}")
|
|
536
|
+
elif st == SkillDiff.STATUS_MODIFIED:
|
|
537
|
+
print(f" [!] {name:30} -> {detail}")
|
|
538
|
+
elif st == SkillDiff.STATUS_CUSTOM:
|
|
539
|
+
print(f" [#] {name:30} -> {detail}")
|
|
540
|
+
for act in planned:
|
|
541
|
+
if not act.startswith("Would install new skill") and not act.startswith("Would update skill") and not act.startswith("Would update modified skill"):
|
|
542
|
+
print(f" [*] {act}")
|
|
543
|
+
print("-----------------------------------------------------------------")
|
|
544
|
+
|
|
545
|
+
print("[OK] Workspace synchronization complete.")
|
|
546
|
+
print("=================================================================\n")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def main():
|
|
550
|
+
parser = argparse.ArgumentParser(description="CTF-Agent Workspace & Skill Updater")
|
|
551
|
+
parser.add_argument("target", nargs="?", help="Target CTF workspace directory (default: current directory)")
|
|
552
|
+
parser.add_argument("--global", dest="is_global", action="store_true", help="Update global Antigravity config (~/.gemini/config/)")
|
|
553
|
+
parser.add_argument("--skills-only", action="store_true", help="Only update skills, preserving rules, agents, and scripts")
|
|
554
|
+
parser.add_argument("--dry-run", action="store_true", help="Inspect and display planned changes without modifying files")
|
|
555
|
+
parser.add_argument("--force", "-f", action="store_true", help="Overwrite modified skills without interactive prompt")
|
|
556
|
+
parser.add_argument("--json", action="store_true", help="Output update report as JSON")
|
|
557
|
+
|
|
558
|
+
args = parser.parse_args()
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
if args.is_global:
|
|
562
|
+
result = WorkspaceUpdater.update_globally(dry_run=args.dry_run, force=args.force)
|
|
563
|
+
else:
|
|
564
|
+
workspace = WorkspaceUpdater.resolve_workspace(args.target)
|
|
565
|
+
result = WorkspaceUpdater.update_workspace(
|
|
566
|
+
workspace,
|
|
567
|
+
skills_only=args.skills_only,
|
|
568
|
+
dry_run=args.dry_run,
|
|
569
|
+
force=args.force,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
if args.json:
|
|
573
|
+
print(json.dumps(result, indent=2))
|
|
574
|
+
else:
|
|
575
|
+
print_update_report(result)
|
|
576
|
+
|
|
577
|
+
except Exception as e:
|
|
578
|
+
print(f"\n[!] Error during update: {e}", file=sys.stderr)
|
|
579
|
+
sys.exit(1)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
if __name__ == "__main__":
|
|
583
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CTFd Platform Automation Client (v2.0)
|
|
4
|
+
Handles authentication, pagination, challenge files, hints, and flag submission.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import urllib.request
|
|
12
|
+
import urllib.error
|
|
13
|
+
from typing import Optional, Dict, Any, List
|
|
14
|
+
|
|
15
|
+
class CTFdClient:
|
|
16
|
+
def __init__(self, base_url: str, api_token: str):
|
|
17
|
+
self.base_url = base_url.rstrip("/")
|
|
18
|
+
self.headers = {
|
|
19
|
+
"Authorization": f"Token {api_token}",
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
"User-Agent": "CTF-Agent-PlatformClient/2.0"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def _request(self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
|
25
|
+
url = f"{self.base_url}/api/v1/{endpoint.lstrip('/')}"
|
|
26
|
+
payload = json.dumps(data).encode("utf-8") if data else None
|
|
27
|
+
req = urllib.request.Request(url, data=payload, headers=self.headers, method=method)
|
|
28
|
+
|
|
29
|
+
for attempt in range(3):
|
|
30
|
+
try:
|
|
31
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
32
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
33
|
+
except urllib.error.HTTPError as e:
|
|
34
|
+
if e.code == 429:
|
|
35
|
+
time.sleep(2 ** attempt)
|
|
36
|
+
continue
|
|
37
|
+
return None
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def get_challenges(self) -> List[Dict[str, Any]]:
|
|
43
|
+
"""Retrieve all challenges across all paginated pages."""
|
|
44
|
+
all_challenges = []
|
|
45
|
+
page = 1
|
|
46
|
+
while True:
|
|
47
|
+
res = self._request("GET", f"challenges?page={page}")
|
|
48
|
+
if not res or not res.get("success") or not res.get("data"):
|
|
49
|
+
break
|
|
50
|
+
all_challenges.extend(res["data"])
|
|
51
|
+
pagination = res.get("meta", {}).get("pagination", {})
|
|
52
|
+
if page >= pagination.get("pages", 1):
|
|
53
|
+
break
|
|
54
|
+
page += 1
|
|
55
|
+
return all_challenges
|
|
56
|
+
|
|
57
|
+
def get_challenge_details(self, challenge_id: int) -> Optional[Dict[str, Any]]:
|
|
58
|
+
res = self._request("GET", f"challenges/{challenge_id}")
|
|
59
|
+
return res.get("data") if res else None
|
|
60
|
+
|
|
61
|
+
def get_challenge_files(self, challenge_id: int) -> List[str]:
|
|
62
|
+
"""Fetch distinct attachment URLs for a challenge."""
|
|
63
|
+
res = self._request("GET", f"challenges/{challenge_id}/files")
|
|
64
|
+
if res and res.get("success"):
|
|
65
|
+
return [f.get("location") for f in res.get("data", [])]
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
def get_challenge_hints(self, challenge_id: int) -> List[Dict[str, Any]]:
|
|
69
|
+
res = self._request("GET", f"challenges/{challenge_id}/hints")
|
|
70
|
+
return res.get("data", []) if res else []
|
|
71
|
+
|
|
72
|
+
def submit_flag(self, challenge_id: int, flag: str) -> Dict[str, Any]:
|
|
73
|
+
data = {"challenge_id": challenge_id, "submission": flag}
|
|
74
|
+
res = self._request("POST", "challenges/attempt", data=data)
|
|
75
|
+
if res and res.get("success"):
|
|
76
|
+
status = res.get("data", {}).get("status", "incorrect")
|
|
77
|
+
return {"status": status, "message": res.get("data", {}).get("message", "")}
|
|
78
|
+
return {"status": "error", "message": "Failed to communicate with CTFd"}
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
print("CTFdClient module initialized. Import in solve scripts or CTF automation tools.")
|