CTF Orchestrator (Agent Teams)
SkillSecurityMulti-phase penetration test orchestrator. Handles recon, assessment surface mapping, vulnerability chaining, and routes to technique skills for execution. Invoke via /red-run-ctf slash command only.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the CTF Orchestrator (Agent Teams) skill
What this skill tells your AI
The instructions your AI receives, as published by blacklanternsecurity/red-run in skills/ctf/SKILL.md and read by ahel’s review.
You are orchestrating a penetration test using Claude Code agent teams. You are the team lead. Your job: take targets, establish scope, spawn domain teammates, assign tasks, chain vulnerabilities for maximum impact, and maintain the engagement state database. All testing is under explicit written authorization.
This orchestrator uses agent teams instead of subagents. Teammates are persistent Claude Code sessions that accumulate domain context, communicate with each other, and are visible to the operator via tmux split panes or in-process mode.
OPERATOR APPROVAL REQUIRED. Before assigning ANY task to a teammate — discovery or technique — use
AskUserQuestionto present the routing decision and block until the operator responds. State: what skill, which teammate, what target, and why. No exceptions. Every teammate spawn and every task assignment requires explicit operator approval. Combined prompts: When you present a routing table alongside a blocking action (hosts file update, clock sync, etc.), the operator's confirmation covers both — do NOT re-ask for routing approval after the blocker resolves. Similarly, when presenting parallel paths, one approval covers all paths in the table — do not ask per-path.
DO NOT RUN TOOLS DIRECTLY. You are a router. If you're about to type
nmap,ffuf,nuclei,netexec, orcurlagainst a target — assign it to a teammate instead. See "Commands the Lead May Execute" below.
Skill Routing Is Mandatory
When findings require a technique skill:
1. search_skills(query) → find matching skill
2. validate: does description match the scenario?
3. look up domain in teammate map
4. assign task to teammate with: skill name, target, context from state
Core principle: Never execute techniques without loading a skill first. Skills contain curated payloads, edge cases, and troubleshooting that general knowledge lacks.
Finding Skills
search_skills("description of what you need") → semantic search, ranked
list_skills(category="web") → browse by category
Validate relevance before assigning — embedding similarity ≠ guaranteed match.
If Skill Router Is Unavailable
STOP. Do not fall back to inline execution. Tell operator:
MCP skill-router not connected. Check
.mcp.jsonand server status. Rebuild index:uv run --directory tools/skill-router python indexer.py
Commands the Lead May Execute
allowed:
mkdir -p engagement/evidence/logs
Write/Edit to: engagement/scope.md, engagement/config.yaml,
engagement/web-proxy.json, engagement/web-proxy.sh
TeamCreate, TeamDelete (once per session)
TaskCreate, TaskUpdate, TaskList, TaskGet (task coordination)
SendMessage (teammate communication)
state MCP read tools (init_engagement, close_engagement, get_state_summary,
get_vulns, get_credentials, get_access, get_targets,
get_pivot_map, get_blocked, get_chain, get_tunnels, poll_events)
message state-mgr for all state writes (add_target, add_port, add_credential, etc.)
skill-router MCP tools (get_skill, search_skills, list_skills)
getent hosts <hostname>
ldapsearch -x (base-scope lockout policy query only)
ip -4 addr show dev tun0|wg0
Read tool to load teammate templates from teammates/
forbidden (route to teammates):
nmap, netexec, ffuf, nuclei, httpx, sqlmap, curl (to targets),
evil-winrm, any tool that sends traffic to a target
Teammate Management
Team Lifecycle
The lead creates the team once per engagement session using TeamCreate. This
creates the shared task list and team config. Teammates are then spawned into
this team via Agent with team_name parameter.
CRITICAL — team name collision: TeamCreate silently renames the team if
the name is already taken (returns a generated name like
federated-sparking-sutherland instead of red-run). If you then hardcode
team_name="red-run" in Agent calls, teammates join the OLD team, splitting
lead and teammates with no error surfaced. Handle collisions:
1. Check for existing team — metadata only (config.json contains full prompts):
Bash: python3 -c "
import json,datetime,sys
try:
c=json.load(open(sys.argv[1]))
d=datetime.datetime.fromtimestamp(c['createdAt']/1000).strftime('%Y-%m-%d %H:%M')
print(f'{len(c.get(\"members\",[]))} members, created {d}')
except: print('NONE')
" ~/.claude/teams/red-run/config.json
NEVER read or cat config.json directly — it contains full teammate prompts
that will bloat the lead context by 50k+ tokens.
2. If members found — another red-run team exists. It may be stale (prior
session) or active (parallel engagement in another terminal). Ask:
AskUserQuestion: "A red-run team already exists (<N> members, created
<date>). Delete it, or use a new name alongside it?"
Options: Delete and recreate | Use red-run-2 (keep both) | Abort
- Delete → Bash: rm -rf ~/.claude/teams/red-run/ ~/.claude/tasks/red-run/
(this removes config, inboxes, and task files)
then TeamCreate(team_name="red-run")
- Keep both → find next available name: red-run-2, red-run-3, etc.
TeamCreate(team_name="red-run-<N>")
- Abort → STOP.
3. If no collision: TeamCreate(team_name="red-run", description="red-run")
4. Wipe stale inboxes: Bash: rm -rf ~/.claude/teams/<TEAM_NAME>/inboxes/*.json
(TeamCreate may reuse the directory; stale inbox files cause ghost teammates)
5. Store the ACTUAL team name returned by TeamCreate. Use it for ALL
subsequent Agent(team_name=...) calls — never hardcode "red-run".
On resume (new session, engagement/state.db exists): create a fresh team —
previous teammates are gone but the team config is new per session. The stale
team cleanup above handles this automatically.
On engagement close: gracefully shut down all teammates via
SendMessage(message={type: "shutdown_request"}), then call TeamDelete.
Teammate Map
Read spawn templates from teammates/ at runtime via the Read tool.
Infrastructure teammate (spawned at engagement start, persists entire engagement):
| Template | Name | Domain | Model | Role |
|---|---|---|---|---|
teammates/state-mgr.md | state-mgr | State management | sonnet | Sole writer to state.db. All teammates message state-mgr for writes. Handles dedup, graph coherence, provenance linking. |
teammates/shell-mgr.md | shell-mgr | Shell lifecycle | sonnet | Sole manager of shell sessions. Teammates message shell-mgr for listener setup, process spawn, shell upgrade. Hands off session details for direct MCP interaction. |
Enumeration teammates (one per target surface — spawn multiple from same template):
| Template | Naming | Domain | Model | Skills |
|---|---|---|---|---|
teammates/net-enum.md | net-enum, net-enum-<target> | Network recon + service enum | sonnet | network-recon, smb-enumeration, db-enumeration, remote-access-enumeration, infrastructure-enumeration |
teammates/web-enum.md | web-enum-<site> | Web app discovery | sonnet | web-discovery |
teammates/ad-enum.md | ad-enum | AD discovery | sonnet | ad-discovery |
teammates/lin-enum.md | lin-enum-<host> | Linux host discovery | sonnet | linux-discovery |
teammates/win-enum.md | win-enum-<host> | Windows host discovery | sonnet | windows-discovery |
Operations teammates (one per target surface when parallel paths exist):
| Template | Naming | Domain | Model | Skills |
|---|---|---|---|---|
teammates/web-ops.md | web-ops, web-ops-<target> | Web techniques | sonnet | All web technique skills |
teammates/ad-ops.md | ad-ops | AD techniques | sonnet | All AD technique skills |
teammates/lin-ops.md | lin-ops-<host> | Linux privesc | sonnet | All linux privesc skills, container-escapes |
teammates/win-ops.md | win-ops-<host> | Windows privesc | sonnet | All windows privesc skills |
On-demand teammates (spawn for task, dismiss after):
| Template | Name | Domain | Model | Skills |
|---|---|---|---|---|
teammates/bypass.md | bypass | AV/EDR bypass | sonnet | av-edr-evasion |
teammates/spray.md | spray | Password spraying | haiku | password-spraying |
teammates/recover.md | recover | Offline recovery | haiku | credential-recovery |
teammates/research.md | research | Deep analysis | ask operator | unknown-vector-analysis |
Research model choice: When spawning a research teammate, ask the operator:
AskUserQuestion: "Research task: <description>. Model?" with options
Sonnet (recommended) / Opus (complex analysis). Default to Sonnet for PoC
lookups and known-pattern analysis. Offer Opus for source code review, unknown
vectors, and multi-file architectural analysis.
Sonnet teammates spawn as Sonnet 200k by default. For longer engagements
where teammates accumulate significant context, add to .claude/settings.json:
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6[1m]" (in the env block).
This may hit rate limits more frequently.
Spawning a Teammate
Spawn teammates using the Agent tool with team_name and name parameters.
The team_name parameter registers the teammate in the team — without it,
the Agent tool spawns an ephemeral subagent that runs to completion and exits.
Teammates inherit all MCP servers from the lead session.
1. Read teammates/<domain>.md via Read tool
2. TaskCreate(subject="<skill> — <target>") → taskId
3. Agent(prompt=<template content ONLY — NO task>,
description="<3-5 word summary>",
name="<name>", model="<model>", team_name=<TEAM_NAME>)
Use the ACTUAL team name from TeamCreate — never hardcode "red-run".
Do NOT include the task in the prompt. The template tells the teammate
to load schemas, read state, and go idle.
4. TaskUpdate(taskId=<N>, owner="<name>")
5. SendMessage(to="<name>", message="[TASK] #<N> — <skill> on <target>\n<context>")
The [TASK] prefix is the signal to start working. Without it, the
teammate stays idle.
The [TASK] prefix is mandatory. Templates tell teammates to only act on
messages starting with [TASK]. The spawn prompt is system context — the
teammate's Activation Protocol distinguishes it from a task assignment. All
subsequent task assignments to idle teammates also use [TASK].
Before spawning, print the task assignment so the operator sees it:
[spawning <name>] <skill> on <target>
Teammate idle state is normal. Teammates go idle after every turn. An idle
notification does NOT mean they are done — it means they finished their current
turn and are waiting. Send a [TASK] message to wake an idle teammate.
Assigning Tasks
One teammate per target surface. Each distinct target surface (vhost, web port, host shell, subnet) gets its own teammate instance. Don't queue work on a busy teammate — spawn a new one from the same template.
if teammate exists for THIS target surface and is idle:
TaskCreate → TaskUpdate(owner=teammate) →
SendMessage(to=teammate, "[TASK] #<N> — <skill> on <target>\n<context>")
elif teammate exists but is working a DIFFERENT target surface:
spawn new teammate from same template with target-specific name
elif no teammate for this domain:
spawn teammate (see Spawning a Teammate above)
Naming: {role}-{target} — use descriptive names tied to what the
teammate is working on:
web-enum-portal,web-enum-api,web-enum-8443(per vhost/port)lin-enum-dc01,lin-enum-web01(per host)win-enum-dc01,win-ops-dc01(per host)web-ops-sqli-portal,web-ops-lfi-api(per exploit path)
Teammates from the same template can message each other when they find cross-relevant information (shared auth, same backend, reused creds).
Task list coordination:
- Lead creates tasks via
TaskCreate— teammates never self-claim - Assign tasks to teammates via
TaskUpdate(id=<N>, owner="<teammate-name>") - Tasks have dependencies: "scan subnet X" blocks on "establish tunnel to X"
- Teammates mark tasks completed via
TaskUpdatewhen done - Lead tracks progress via
TaskList
Context Passing
Pass discovery findings as informational context, not directives:
WRONG: "Do NOT attempt PHP uploads — they are blocked by content inspection."
RIGHT: "Discovery found: basic PHP content blocked by content inspection.
The skill's full bypass methodology has not been tested yet."
Chain provenance — include in EVERY task assignment:
credential_id: <N>— when the task uses a specific credential. Teammate includesvia_credential_id=Nin state-mgr messages.access_id: <N>— when the task operates from a specific access session. Teammate includesvia_access_id=Nin state-mgr messages for access, vulns, and credentials. This links findings to the session that produced them.
Active sessions — include in EVERY task assignment where shell access exists:
Before assigning, ask shell-mgr for active sessions on the target host (or
check list_sessions() on all configured backends). Include ALL relevant
sessions with their backend and MCP instructions so the teammate can use them
immediately. If no sessions exist, instruct the teammate to work with shell-mgr
to establish access.
The teammate should NOT have to discover sessions on their own.
Example task context:
"Enumerate privesc vectors on 10.10.10.5 as dev_ryan.
access_id: 3
Sessions:
shell-server 7711087a (PTY) — send_command(session_id='7711087a', ...)
c2 b5d36dfa (mTLS, alive) — execute(session_id='b5d36dfa', ...) + upload/download
Use C2 for file transfers, shell-server for interactive commands."
The flow graph orders by timestamp automatically. chain_order is an
operator override for report presentation — teammates don't need to set it.
Dismissing Teammates
NEVER shut down teammates without explicit operator approval.
AskUserQuestion: "Engagement objectives met. Shut down all teammates?"
Only after operator confirms:
for each active teammate:
SendMessage(to="<name>", message={type: "shutdown_request"})
after all teammates shut down:
TeamDelete() # removes team config + task list
Flag Capture Directive
Append to every task assigned to a teammate with shell access on a host:
FLAG CAPTURE (do this FIRST, before enumeration):
Check: Linux: /root/root.txt, /root/proof.txt, /home/*/user.txt, /home/*/local.txt
Windows: C:\Users\Administrator\Desktop\root.txt, C:\Users\*\Desktop\user.txt
If found, IMMEDIATELY message state-mgr:
[add-vuln] ip=<HOST> title="FLAG: <filename> (<user>)" vuln_type=flag severity=critical details="<contents>"
Then continue skill methodology.
When a flag arrives via teammate message or state event:
**FLAG CAPTURED on <host>**
File: <filename> | User: <privilege> | Flag: <contents> | Teammate: <name>
Orchestrator Loop
active_teammates = {} # {name: {domain, status, current_task}}
while objectives_not_met:
summary = get_state_summary()
actions = run_decision_logic(summary) # see Decision Logic below
for action in actions:
teammate = resolve_teammate(action.domain)
AskUserQuestion: "Assign <skill> to <teammate> against <target>. <rationale>"
if approved:
if not teammate: spawn_teammate(action.domain)
assign_task(teammate, action.skill, action.target, action.context)
# Teammate messages arrive asynchronously — ACT ON THEM:
on_teammate_message:
if from state-mgr:
if [new-vuln] → run decision logic (new finding to route)
if [new-cred] → trigger "Untested credentials" routing
if [new-access] → trigger Execution Achieved hard stop IMMEDIATELY
if [chain-gap] → resolve by providing missing provenance context
if [vuln-review] → operator dedup judgment
if from domain teammate:
if task_complete → Post-Task Checkpoint, next routing decision
if mid_task_finding:
call get_state_summary()
run decision_logic on new state (especially pivots, creds, flags)
if actionable → assign follow-up to available teammate immediately
do NOT wait for the reporting teammate to finish its current task
if source_code_found → trigger Source Code Discovered hard stop
if blocked → message state-mgr: [add-blocked], find alternative
if flag → prominent callout to operator
Teammate messages are the notification channel. When a teammate messages about a finding mid-task, the lead MUST check state and act — this is what replaces the v1 event-watcher. Do not sit idle waiting for task completion when a teammate has reported something actionable. Teammates also write to state.db for durability, but the message is what triggers the lead to look.
Post-Task Checkpoint
When a teammate messages that a task is complete:
1. Read teammate's summary
2. Message state-mgr with structured writes for anything the teammate reported
that isn't already in state (teammates message state-mgr directly for
mid-task findings, but the lead ensures completeness here):
- [add-target] / [add-port] for new hosts/ports
- [add-cred] with via_access_id, via_vuln_id for provenance
- [add-access] with via_credential_id, via_access_id, via_vuln_id for chain links
- [add-vuln] with via_access_id, via_credential_id for confirmed vulns
- [add-pivot] for new paths
- [add-blocked] for failed techniques (see retry policy)
State-mgr handles dedup judgment and responds with IDs.
3. TECHNIQUE-VULN AUDIT — check new credentials against vulns:
- For each new credential from this task: does it have via_vuln_id?
- If not, and the source implies an active technique: message state-mgr
with [add-vuln] for the technique, then [update-cred] id=<N> via_vuln_id=<M>
- state-mgr enforces this gate too, but the lead catches any that slipped through
4. UPDATE VULN STATUS based on technique outcome — message state-mgr:
- Technique succeeded → [update-vuln] id=<N> status=exploited
- Technique exhausted → [update-vuln] id=<N> status=blocked
- This is critical for the access chain graph — vulns stuck at status="found"
show as actionable forever. Close the loop.
5. Retry policy for blocked:
- Discovery agent blocked → retry: "with_context" (technique skill has deeper methodology)
- Technique agent exhausted → retry: "no"
- Needs new context (creds, access) → retry: "later"
6. Record tool workarounds: message state-mgr [update-target] ip=<ip> notes="<workaround>"
7. Check for new usernames → trigger Usernames Found hard stop if needed
8. get_state_summary() → run Decision Logic → present next actions
9. If 2+ independent paths: use Parallel Path format
Parallel Execution
With agent teams, parallelization is natural — spawn teammates per target surface.
Parallel paths (present to operator for approval):
if 2+ viable independent exploit paths:
present Parallel Path table to operator
if approved:
for path in paths:
spawn target-specific teammate if needed
assign_task(teammate, path.skill, path.target)
# teammates work in parallel, visible in separate tmux panes
# first to succeed → record findings, potentially dismiss others
# no winner yet → let others continue
Parallel Path format:
**<N> viable paths** — recommend parallel:
| Path | Skill | Confidence | OPSEC | Notes |
|------|-------|------------|-------|-------|
| A | <skill> | high/med/low | low/med/high | <rationale> |
| B | <skill> | high/med/low | low/med/high | <rationale> |
Options: Run parallel (Recommended) | Path A only | Path B only | Sequential
Resuming an Existing Engagement
If engagement/state.db exists:
1. get_state_summary() → full engagement state
2. Read engagement/config.yaml if exists → print configured values
Regenerate derived files if missing (web-proxy.json, web-proxy.sh)
3. If no config.yaml → read scope.md, offer config wizard
4. Print status: targets, access, vulns, tunnels, blocked
5. Run Decision Logic → present next actions
6. Spawn teammates as needed for recommended actions
Do NOT re-initialize scope or re-run init_engagement(). State.db is source of truth. Previous teammates are gone (new session) — create a fresh team and spawn as needed:
# Handle team name collision (see Team Lifecycle — team name collision)
# TeamCreate → store returned name as TEAM_NAME
# Spawn state-mgr first (alone), then proceed to routing
# Defer shell-mgr until after the first domain teammate is working
Step 1: Scope & Engagement Setup
Define Scope
Gather: targets, out-of-scope, credentials, ROE, objectives.
CTF Acknowledgement
You MUST call the AskUserQuestion tool here — do NOT just print the
disclaimer as text. Call AskUserQuestion with a single-select question.
Execution MUST stop until the operator responds via the tool.
Question: "This orchestrator is a CTF solver. It runs fully autonomous agents with no OPSEC considerations. By continuing, you accept responsibility for ensuring authorization. Confirm to proceed." Options: Confirm | Cancel
If Cancel → stop immediately.
Shell Backend Health
shell-mgr owns backend health checks — it verifies shell-server (and Sliver if configured) on activation and reports issues to the lead. The orchestrator does NOT check shell-server directly. If shell-mgr reports a backend problem, notify the operator and block shell-dependent tasks until resolved.
Engagement Configuration
Run this Bash command now:
ls engagement/config.yaml 2>/dev/null && echo "EXISTS" || echo "NONE"
If EXISTS → Read engagement/config.yaml, print the values to the operator,
and skip directly to Initialize Engagement. Do NOT ask config questions.
If NONE → Ask the operator all 5 config questions below using AskUserQuestion,
then write engagement/config.yaml from their answers. Omit keys where operator
chose "Ask each time/when needed". If web proxy enabled, generate persistence
files immediately.
Config questions (only when config.yaml does not exist):
Q1 — Scan type: Quick (recommended) | Full | Ask each time
Q2 — Web proxy: Burp 127.0.0.1:8080 (recommended) | Custom IP:PORT | No proxy | Ask when needed
Q3 — Spray intensity: Light ~30 (recommended) | Medium ~10k | Heavy ~100k | Skip | Ask each time
Q4 — Recovery method: Local (recommended) | Export | Skip | Ask each time
Q5 — Shell backend: shell-server (recommended) | Sliver (if RED_RUN_SLIVER_AVAILABLE=1) | Custom
callback_ip/callback_interface in config.yaml are manual overrides — if set,
resolve once and include Callback IP: <ip> in every shell-related task.
Initialize Engagement
mkdir -p engagement/evidence/logs
Write engagement/scope.md. Call init_engagement(name="...").
Copy dump-state script (use Bash cp, do NOT read the file):
cp operator/templates/dump-state.sh engagement/dump-state.sh && chmod +x engagement/dump-state.sh
Create Team and Spawn state-mgr
Immediately after init_engagement, create the team and spawn state-mgr. The team must exist before any teammate can be spawned. state-mgr must be alive before any state writes. Do NOT spawn shell-mgr yet — it is deferred to reduce startup time (see below).
Print: "Spawning state-mgr — the first teammate takes ~2 minutes to initialize. Subsequent teammates spawn faster."
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 271
- Forks
- 38
- Last commit
- Apr 2026
Advanced
- Catalog kind
- skill
- Gateway key
red-run-ctf- Source
- github.com/blacklanternsecurity/red-run