ctfy.sdk.admin_resources.awd
client.admin.awd — configuring a classic-AWD match.
1"""``client.admin.awd`` — configuring a classic-AWD match.""" 2 3from __future__ import annotations 4 5from collections.abc import Mapping 6from datetime import datetime 7 8from ctfy.core.enums import ( 9 AwdAttacker, 10 AwdDefenceMode, 11 AwdOpenShellCredential, 12 AwdShellExposure, 13) 14from ctfy.sdk._helpers import _raise_for_status 15from ctfy.sdk.base import BaseHttpClient 16from ctfy.server.models import ( 17 AdminTaskInfo, 18 AwdArenaPeersResponse, 19 AwdAttackAnchorResponse, 20 AwdCorroborationResponse, 21 AwdDefenceAnchorResponse, 22 AwdForensicsResponse, 23 AwdMatchInfo, 24 AwdProvisionPlanInfo, 25 AwdSlaHistoryResponse, 26) 27 28 29class AdminAwdResource: 30 """The organiser's half of a match: cadence, gamebox, budgets. 31 32 ⚠️ **The round number is derived from the clock, not stored**, so 33 ``starts_at`` / ``tick_seconds`` / ``challenge_id`` are frozen once 34 the match has started — editing one would retroactively renumber 35 every round already settled and published. :meth:`configure` 36 answers 409 ``awd_match_locked`` naming the fields, rather than 37 silently accepting a write that breaks the board. 38 39 There is deliberately no way to set ``last_tick_done``: it is the 40 tick loop's bookmark, and moving it makes the loop re-settle rounds 41 the board has already published. 42 """ 43 44 def __init__(self, http: BaseHttpClient) -> None: 45 self._http = http 46 47 def get(self, competition_id: str) -> AwdMatchInfo: 48 """This competition's match. 404 if it runs none. 49 50 ``current_tick`` and ``started`` are computed per read — asking 51 again a round later gives a different answer with no write in 52 between, which is the point. 53 """ 54 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/match") 55 _raise_for_status(resp) 56 return AwdMatchInfo.model_validate(resp.json()) 57 58 def configure( 59 self, 60 competition_id: str, 61 *, 62 tick_seconds: int, 63 tick_count: int, 64 challenge_id: str = "", 65 starts_at: datetime | None = None, 66 per_tick_budget: int | None = None, 67 total_budget: int | None = None, 68 attacker: str = AwdAttacker.NONE.value, 69 reference_victim: bool = False, 70 defence_mode: str = AwdDefenceMode.PATCH.value, 71 shell_exposure: str = AwdShellExposure.MANAGED.value, 72 open_shell_credential: str = AwdOpenShellCredential.OWNER_RANDOM.value, 73 scoring_params: Mapping[str, float] | None = None, 74 ) -> AwdMatchInfo: 75 """Create the match, or edit it. Idempotent on the competition. 76 77 ``challenge_id`` may be left empty while the match is being put 78 together — provisioning is what refuses without a gamebox, and 79 it refuses at the point an organiser can still act on it. 80 81 ⚠️ **This is a full replace, and every argument above is 82 therefore a value rather than an option.** The route writes 83 ``existing.model_copy(update=body)``, so an argument left at its 84 default is *written*, not skipped: calling this to change the 85 cadence of an armed match disarms the platform attacker and 86 discards the organiser's scoring knobs. Read the row and pass it 87 back if you mean "edit one field" — which is what ``ctfy-admin 88 admin awd set`` does, and why it exists as more than a wrapper. 89 90 Kept as a replace on purpose: a caller that means *disarm this* 91 has to be able to say so, and a library that quietly reads the 92 current row before writing turns one request into a 93 read-then-write race whose loser is somebody else's edit. 94 """ 95 body: dict[str, object] = { 96 "challenge_id": challenge_id, 97 "starts_at": starts_at.isoformat() if starts_at else None, 98 "tick_seconds": tick_seconds, 99 "tick_count": tick_count, 100 "per_tick_budget": per_tick_budget, 101 "total_budget": total_budget, 102 "attacker": attacker, 103 "reference_victim": reference_victim, 104 "defence_mode": defence_mode, 105 "shell_exposure": shell_exposure, 106 "open_shell_credential": open_shell_credential, 107 "scoring_params": dict(scoring_params or {}), 108 } 109 resp = self._http.request( 110 "PUT", f"/admin/competitions/{competition_id}/awd/match", json=body 111 ) 112 _raise_for_status(resp) 113 return AwdMatchInfo.model_validate(resp.json()) 114 115 def delete(self, competition_id: str) -> None: 116 """Remove a match that never started and holds no boxes. 117 118 Refused once the first round has opened — its scores are 119 published and keyed by round, so the configuration they belong 120 to has to stay. Lowering ``tick_count`` is how a running match 121 is ended. 122 """ 123 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/match") 124 _raise_for_status(resp) 125 126 def plan(self, competition_id: str) -> AwdProvisionPlanInfo: 127 """What opening the arena would do, without doing it. 128 129 409 ``awd_provision_refused`` when the match cannot be 130 provisioned at all — no gamebox, no teams, or a match that has 131 already started (§4.5 fixes the box supply at kickoff). 132 """ 133 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/plan") 134 _raise_for_status(resp) 135 return AwdProvisionPlanInfo.model_validate(resp.json()) 136 137 def arena_peers(self, competition_id: str) -> AwdArenaPeersResponse: 138 """Who is on this match's arena, and whether their tunnel carries. 139 140 One row per person: the worker their tunnel terminates on, the 141 address they hold, and what the last round's probe found — no 142 handshake, a handshake with no ping, or small packets through and 143 full-size ones not, which are three different repairs on three 144 different machines. 145 146 ``link`` is ``None`` when no round has probed that peer yet, 147 which is deliberately not the same as a peer that is down. 148 149 Carries no config and no key: knowing *that* somebody is 150 connected is the job, and the private half is stored nowhere. 151 """ 152 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/vpn-peers") 153 _raise_for_status(resp) 154 return AwdArenaPeersResponse.model_validate(resp.json()) 155 156 def corroboration(self, competition_id: str, tick: int) -> AwdCorroborationResponse: 157 """Which of a round's captures the victims' own traffic backs up. 158 159 A flag is bound to ``(victim, service, tick)`` and not to whoever 160 went and got it, so passing one between teams scores — a 161 structural hole in AWD. What a platform with packet capture adds 162 is cost: an attacker who really took the flag was on the box, so 163 their arena address is in that box's capture. 164 165 **Reports, never rules.** No score moves and no flag is refused; 166 the submission path does not call this. ``verdict`` is 167 three-valued and only ``absent`` says anything about the 168 attacker — ``unreadable`` is every way of not finding somebody 169 that is not evidence *about* them (no sidecar on their box, a 170 box that recorded nothing because its service is not HTTP, or an 171 attacking team with no arena address on record at all). 172 173 ``totals`` carries every verdict at zero, so a round that is 174 entirely unreadable says so rather than reading as "no 175 findings". 176 """ 177 resp = self._http.request( 178 "GET", 179 f"/admin/competitions/{competition_id}/awd/corroboration", 180 params={"tick": tick}, 181 ) 182 _raise_for_status(resp) 183 return AwdCorroborationResponse.model_validate(resp.json()) 184 185 def defence_anchor(self, competition_id: str) -> AwdDefenceAnchorResponse: 186 """Who blocked the reference exploit, and over how many rounds. 187 188 A model-vs-model ranking is **relative** — a weak field flatters 189 everybody on it — so it does not compare across matches, which 190 for an evaluation product is the whole problem. The reference 191 attacker is the fixed half: every entrant, in every match, meets 192 the same ``solution/exploit.py``, so "held N of the M rounds it 193 ran" carries the same meaning everywhere. 194 195 ⚠️ **M is not the round count.** ``held`` is counted only over 196 rounds in which the exploit actually executed: a round the 197 platform never attacked and a round the defender held leave the 198 same absence, so a scale derived from captures alone credits an 199 entrant for every round the platform failed to attack them. 200 ``not_attempted`` is how much of the match went unmeasured, and 201 it is the platform's fault rather than theirs. 202 203 ``hold_rate`` is ``None`` — never ``0.0`` — for an entrant with 204 no attempted rounds: a zero would assert they were breached 205 every round. 206 207 ``armed`` is ``False`` for a match that runs no reference 208 attacker at all, which is a different thing from an attacker 209 that found nothing. 210 """ 211 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/anchor") 212 _raise_for_status(resp) 213 return AwdDefenceAnchorResponse.model_validate(resp.json()) 214 215 def sla_history(self, competition_id: str, *, team_id: str = "") -> AwdSlaHistoryResponse: 216 """When in each round this match's SLA probes actually landed. 217 218 A check row records *which* round and, until recently, nothing 219 about *when in it* — so "was my box probed before I had a chance 220 to repair it", the question a disputed deduction turns on, could 221 only be answered by reading the tick loop. Each row here carries 222 the seconds from its own round's opening, and ``spread_s`` is how 223 much later the last box was asked than the first. 224 225 ``offset_s`` is ``None`` for a row written before the column 226 existed, and for a match with no kickoff to measure from. It is 227 never ``0.0`` for those: zero means the probe landed on the 228 round's opening second. 229 """ 230 params: dict[str, str] = {} 231 if team_id: 232 params["team_id"] = team_id 233 resp = self._http.request( 234 "GET", f"/admin/competitions/{competition_id}/awd/sla-history", params=params or None 235 ) 236 return AwdSlaHistoryResponse.model_validate(resp.json()) 237 238 def attack_anchor(self, competition_id: str) -> AwdAttackAnchorResponse: 239 """Who broke the reference victim, and over how many rounds. 240 241 The mirror of :meth:`defence_anchor`. That one fixes the 242 **attacker** every entrant meets; this one fixes the 243 **victim** — §16.6's free target, one unpatched box owned by 244 nobody and never defended — so "broke a known-vulnerable box in 245 N of the M rounds it was up" carries the same meaning in every 246 match, where a place on the relative board is a statement about 247 the field an entrant happened to draw. 248 249 ⚠️ **M is the free box's own SLA record, not the round count.** 250 Nobody can break a box that was down, so debiting every entrant 251 for a round the instrument spent broken makes the number a 252 reading of our uptime rather than their attack. ``offered`` is 253 that denominator and is **match-wide**: every row carries the 254 same value, which is what makes two entrants comparable. 255 256 ``breach_rate`` is ``None`` — never ``0.0`` — when nothing was 257 offered, and ``first_breach_tick`` is ``None`` — never ``-1`` 258 and never ``0``, since round 0 is a real round — for an entrant 259 who never broke it. 260 261 ``armed`` is ``False`` for a match that runs no reference victim 262 at all, which is a different thing from a free box nobody broke. 263 """ 264 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/attack-anchor") 265 _raise_for_status(resp) 266 return AwdAttackAnchorResponse.model_validate(resp.json()) 267 268 def forensics(self, competition_id: str) -> AwdForensicsResponse: 269 """§4.3.1's machine half: who found the attacker in their own box. 270 271 The platform's agent attacker carries a marker per ``(team, 272 round)`` into every gamebox. A defender who instrumented their 273 box can locate where that round's attacker touched it and quote 274 the value back in their engagement report; this is how much of 275 what reached them they found. It is the shallow half on 276 purpose — "did you keep a log and did you read it" — and the 277 model-judged half is what stops a forensic range being a grep 278 exercise. 279 280 ⚠️ **``offered`` is not the round count.** `defence_anchor`'s 281 rule through a third door: the attacker carrying these markers 282 is an LLM and therefore random, so a round it never landed in is 283 a round the *platform* failed to attack. Charging the team for 284 it would make the number a reading of our own uptime wearing 285 their name. 286 287 ``discovery_rate`` is ``None`` — never ``0.0`` — for an entrant 288 nothing reached. 289 290 ⚠️ **``armed`` reads whether the attacker carries an environment 291 at all**, which is a narrower question than "is there an 292 attacker": a reference-exploit match attacks every round and 293 mints no marker, because `build_attack_argv` passes no 294 environment and EXPLOIT007's guarantee rests on that. Every row 295 would then read ``offered: 0``, byte-identical to a field the 296 attacker reached and nobody traced. 297 298 ``reported`` annotates rather than decides: a team with no 299 report and a team whose report quotes nothing score the same, 300 and correctly — what differs is the repair. 301 """ 302 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/forensics") 303 _raise_for_status(resp) 304 return AwdForensicsResponse.model_validate(resp.json()) 305 306 def grade_forensics(self, competition_id: str) -> AdminTaskInfo: 307 """§4.3.1's model half: judge every entrant's write-up, now. 308 309 Returns the **task**, not the readings: this is one model call 310 per entrant, so a field of a dozen is a minute of work. Poll 311 ``client.admin.tasks.get(id)``, then read :meth:`forensics`, 312 whose rows carry ``analysis`` once it has run. 313 314 ⚠️ **A press rather than a cadence**, unlike the exercise 315 grader: a GAUNTLET report is drafted over days and graded on a 316 quiet window, while a match *ends* and the write-up is judged 317 once against the document it finished with. 318 319 ⚠️ **It spends a model call per entrant every press**, and the 320 task reports ``skipped`` rather than refusing when this 321 deployment has no gateway — ``forensics()``'s 322 ``analysis_configured`` is what says so before you press. 323 324 ⚠️ The verdicts are about **depth, never accuracy**: with no 325 answer key a model cannot say whether a stated root cause is 326 right, only whether the report states one specifically. A row 327 reading ``root_cause: specific`` is not the platform agreeing 328 with the defender. 329 """ 330 resp = self._http.request( 331 "POST", f"/admin/competitions/{competition_id}/awd/forensics/grade" 332 ) 333 _raise_for_status(resp) 334 return AdminTaskInfo.model_validate(resp.json()) 335 336 def provision(self, competition_id: str) -> AdminTaskInfo: 337 """Start a gamebox for every team that lacks one. 338 339 Returns the **task**, not the boxes: a fleet launch is minutes 340 of work at event scale. Poll ``client.admin.tasks.get(id)`` (or 341 ``.logs``, which names every team that failed) for the outcome. 342 Idempotent — re-running skips the teams already holding a box. 343 """ 344 resp = self._http.request("POST", f"/admin/competitions/{competition_id}/awd/provision") 345 _raise_for_status(resp) 346 return AdminTaskInfo.model_validate(resp.json()) 347 348 def start_anyway(self, competition_id: str) -> AwdMatchInfo: 349 """Start the match although its arena was not measured whole (#1713). 350 351 The provisioning run checks every link between the arena's nodes 352 before kickoff; when that check is not ``whole`` the tick settles 353 no round until the arena is re-checked whole or somebody presses 354 this. Records who and when as an audit fact. Refused with 409 355 ``awd_kickoff_not_held`` when nothing is being held. 356 """ 357 resp = self._http.request( 358 "POST", f"/admin/competitions/{competition_id}/awd/kickoff-override" 359 ) 360 _raise_for_status(resp) 361 return AwdMatchInfo.model_validate(resp.json()) 362 363 def teardown(self, competition_id: str) -> AdminTaskInfo: 364 """Stop every arena box of this match. Also a task. 365 366 Only this competition's ``lease="match"`` boxes: a player's own 367 on-demand instance is not part of the match, and no node is 368 wiped wholesale — the arena shares hosts with every other event 369 on the platform. 370 """ 371 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/boxes") 372 _raise_for_status(resp) 373 return AdminTaskInfo.model_validate(resp.json())
30class AdminAwdResource: 31 """The organiser's half of a match: cadence, gamebox, budgets. 32 33 ⚠️ **The round number is derived from the clock, not stored**, so 34 ``starts_at`` / ``tick_seconds`` / ``challenge_id`` are frozen once 35 the match has started — editing one would retroactively renumber 36 every round already settled and published. :meth:`configure` 37 answers 409 ``awd_match_locked`` naming the fields, rather than 38 silently accepting a write that breaks the board. 39 40 There is deliberately no way to set ``last_tick_done``: it is the 41 tick loop's bookmark, and moving it makes the loop re-settle rounds 42 the board has already published. 43 """ 44 45 def __init__(self, http: BaseHttpClient) -> None: 46 self._http = http 47 48 def get(self, competition_id: str) -> AwdMatchInfo: 49 """This competition's match. 404 if it runs none. 50 51 ``current_tick`` and ``started`` are computed per read — asking 52 again a round later gives a different answer with no write in 53 between, which is the point. 54 """ 55 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/match") 56 _raise_for_status(resp) 57 return AwdMatchInfo.model_validate(resp.json()) 58 59 def configure( 60 self, 61 competition_id: str, 62 *, 63 tick_seconds: int, 64 tick_count: int, 65 challenge_id: str = "", 66 starts_at: datetime | None = None, 67 per_tick_budget: int | None = None, 68 total_budget: int | None = None, 69 attacker: str = AwdAttacker.NONE.value, 70 reference_victim: bool = False, 71 defence_mode: str = AwdDefenceMode.PATCH.value, 72 shell_exposure: str = AwdShellExposure.MANAGED.value, 73 open_shell_credential: str = AwdOpenShellCredential.OWNER_RANDOM.value, 74 scoring_params: Mapping[str, float] | None = None, 75 ) -> AwdMatchInfo: 76 """Create the match, or edit it. Idempotent on the competition. 77 78 ``challenge_id`` may be left empty while the match is being put 79 together — provisioning is what refuses without a gamebox, and 80 it refuses at the point an organiser can still act on it. 81 82 ⚠️ **This is a full replace, and every argument above is 83 therefore a value rather than an option.** The route writes 84 ``existing.model_copy(update=body)``, so an argument left at its 85 default is *written*, not skipped: calling this to change the 86 cadence of an armed match disarms the platform attacker and 87 discards the organiser's scoring knobs. Read the row and pass it 88 back if you mean "edit one field" — which is what ``ctfy-admin 89 admin awd set`` does, and why it exists as more than a wrapper. 90 91 Kept as a replace on purpose: a caller that means *disarm this* 92 has to be able to say so, and a library that quietly reads the 93 current row before writing turns one request into a 94 read-then-write race whose loser is somebody else's edit. 95 """ 96 body: dict[str, object] = { 97 "challenge_id": challenge_id, 98 "starts_at": starts_at.isoformat() if starts_at else None, 99 "tick_seconds": tick_seconds, 100 "tick_count": tick_count, 101 "per_tick_budget": per_tick_budget, 102 "total_budget": total_budget, 103 "attacker": attacker, 104 "reference_victim": reference_victim, 105 "defence_mode": defence_mode, 106 "shell_exposure": shell_exposure, 107 "open_shell_credential": open_shell_credential, 108 "scoring_params": dict(scoring_params or {}), 109 } 110 resp = self._http.request( 111 "PUT", f"/admin/competitions/{competition_id}/awd/match", json=body 112 ) 113 _raise_for_status(resp) 114 return AwdMatchInfo.model_validate(resp.json()) 115 116 def delete(self, competition_id: str) -> None: 117 """Remove a match that never started and holds no boxes. 118 119 Refused once the first round has opened — its scores are 120 published and keyed by round, so the configuration they belong 121 to has to stay. Lowering ``tick_count`` is how a running match 122 is ended. 123 """ 124 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/match") 125 _raise_for_status(resp) 126 127 def plan(self, competition_id: str) -> AwdProvisionPlanInfo: 128 """What opening the arena would do, without doing it. 129 130 409 ``awd_provision_refused`` when the match cannot be 131 provisioned at all — no gamebox, no teams, or a match that has 132 already started (§4.5 fixes the box supply at kickoff). 133 """ 134 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/plan") 135 _raise_for_status(resp) 136 return AwdProvisionPlanInfo.model_validate(resp.json()) 137 138 def arena_peers(self, competition_id: str) -> AwdArenaPeersResponse: 139 """Who is on this match's arena, and whether their tunnel carries. 140 141 One row per person: the worker their tunnel terminates on, the 142 address they hold, and what the last round's probe found — no 143 handshake, a handshake with no ping, or small packets through and 144 full-size ones not, which are three different repairs on three 145 different machines. 146 147 ``link`` is ``None`` when no round has probed that peer yet, 148 which is deliberately not the same as a peer that is down. 149 150 Carries no config and no key: knowing *that* somebody is 151 connected is the job, and the private half is stored nowhere. 152 """ 153 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/vpn-peers") 154 _raise_for_status(resp) 155 return AwdArenaPeersResponse.model_validate(resp.json()) 156 157 def corroboration(self, competition_id: str, tick: int) -> AwdCorroborationResponse: 158 """Which of a round's captures the victims' own traffic backs up. 159 160 A flag is bound to ``(victim, service, tick)`` and not to whoever 161 went and got it, so passing one between teams scores — a 162 structural hole in AWD. What a platform with packet capture adds 163 is cost: an attacker who really took the flag was on the box, so 164 their arena address is in that box's capture. 165 166 **Reports, never rules.** No score moves and no flag is refused; 167 the submission path does not call this. ``verdict`` is 168 three-valued and only ``absent`` says anything about the 169 attacker — ``unreadable`` is every way of not finding somebody 170 that is not evidence *about* them (no sidecar on their box, a 171 box that recorded nothing because its service is not HTTP, or an 172 attacking team with no arena address on record at all). 173 174 ``totals`` carries every verdict at zero, so a round that is 175 entirely unreadable says so rather than reading as "no 176 findings". 177 """ 178 resp = self._http.request( 179 "GET", 180 f"/admin/competitions/{competition_id}/awd/corroboration", 181 params={"tick": tick}, 182 ) 183 _raise_for_status(resp) 184 return AwdCorroborationResponse.model_validate(resp.json()) 185 186 def defence_anchor(self, competition_id: str) -> AwdDefenceAnchorResponse: 187 """Who blocked the reference exploit, and over how many rounds. 188 189 A model-vs-model ranking is **relative** — a weak field flatters 190 everybody on it — so it does not compare across matches, which 191 for an evaluation product is the whole problem. The reference 192 attacker is the fixed half: every entrant, in every match, meets 193 the same ``solution/exploit.py``, so "held N of the M rounds it 194 ran" carries the same meaning everywhere. 195 196 ⚠️ **M is not the round count.** ``held`` is counted only over 197 rounds in which the exploit actually executed: a round the 198 platform never attacked and a round the defender held leave the 199 same absence, so a scale derived from captures alone credits an 200 entrant for every round the platform failed to attack them. 201 ``not_attempted`` is how much of the match went unmeasured, and 202 it is the platform's fault rather than theirs. 203 204 ``hold_rate`` is ``None`` — never ``0.0`` — for an entrant with 205 no attempted rounds: a zero would assert they were breached 206 every round. 207 208 ``armed`` is ``False`` for a match that runs no reference 209 attacker at all, which is a different thing from an attacker 210 that found nothing. 211 """ 212 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/anchor") 213 _raise_for_status(resp) 214 return AwdDefenceAnchorResponse.model_validate(resp.json()) 215 216 def sla_history(self, competition_id: str, *, team_id: str = "") -> AwdSlaHistoryResponse: 217 """When in each round this match's SLA probes actually landed. 218 219 A check row records *which* round and, until recently, nothing 220 about *when in it* — so "was my box probed before I had a chance 221 to repair it", the question a disputed deduction turns on, could 222 only be answered by reading the tick loop. Each row here carries 223 the seconds from its own round's opening, and ``spread_s`` is how 224 much later the last box was asked than the first. 225 226 ``offset_s`` is ``None`` for a row written before the column 227 existed, and for a match with no kickoff to measure from. It is 228 never ``0.0`` for those: zero means the probe landed on the 229 round's opening second. 230 """ 231 params: dict[str, str] = {} 232 if team_id: 233 params["team_id"] = team_id 234 resp = self._http.request( 235 "GET", f"/admin/competitions/{competition_id}/awd/sla-history", params=params or None 236 ) 237 return AwdSlaHistoryResponse.model_validate(resp.json()) 238 239 def attack_anchor(self, competition_id: str) -> AwdAttackAnchorResponse: 240 """Who broke the reference victim, and over how many rounds. 241 242 The mirror of :meth:`defence_anchor`. That one fixes the 243 **attacker** every entrant meets; this one fixes the 244 **victim** — §16.6's free target, one unpatched box owned by 245 nobody and never defended — so "broke a known-vulnerable box in 246 N of the M rounds it was up" carries the same meaning in every 247 match, where a place on the relative board is a statement about 248 the field an entrant happened to draw. 249 250 ⚠️ **M is the free box's own SLA record, not the round count.** 251 Nobody can break a box that was down, so debiting every entrant 252 for a round the instrument spent broken makes the number a 253 reading of our uptime rather than their attack. ``offered`` is 254 that denominator and is **match-wide**: every row carries the 255 same value, which is what makes two entrants comparable. 256 257 ``breach_rate`` is ``None`` — never ``0.0`` — when nothing was 258 offered, and ``first_breach_tick`` is ``None`` — never ``-1`` 259 and never ``0``, since round 0 is a real round — for an entrant 260 who never broke it. 261 262 ``armed`` is ``False`` for a match that runs no reference victim 263 at all, which is a different thing from a free box nobody broke. 264 """ 265 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/attack-anchor") 266 _raise_for_status(resp) 267 return AwdAttackAnchorResponse.model_validate(resp.json()) 268 269 def forensics(self, competition_id: str) -> AwdForensicsResponse: 270 """§4.3.1's machine half: who found the attacker in their own box. 271 272 The platform's agent attacker carries a marker per ``(team, 273 round)`` into every gamebox. A defender who instrumented their 274 box can locate where that round's attacker touched it and quote 275 the value back in their engagement report; this is how much of 276 what reached them they found. It is the shallow half on 277 purpose — "did you keep a log and did you read it" — and the 278 model-judged half is what stops a forensic range being a grep 279 exercise. 280 281 ⚠️ **``offered`` is not the round count.** `defence_anchor`'s 282 rule through a third door: the attacker carrying these markers 283 is an LLM and therefore random, so a round it never landed in is 284 a round the *platform* failed to attack. Charging the team for 285 it would make the number a reading of our own uptime wearing 286 their name. 287 288 ``discovery_rate`` is ``None`` — never ``0.0`` — for an entrant 289 nothing reached. 290 291 ⚠️ **``armed`` reads whether the attacker carries an environment 292 at all**, which is a narrower question than "is there an 293 attacker": a reference-exploit match attacks every round and 294 mints no marker, because `build_attack_argv` passes no 295 environment and EXPLOIT007's guarantee rests on that. Every row 296 would then read ``offered: 0``, byte-identical to a field the 297 attacker reached and nobody traced. 298 299 ``reported`` annotates rather than decides: a team with no 300 report and a team whose report quotes nothing score the same, 301 and correctly — what differs is the repair. 302 """ 303 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/forensics") 304 _raise_for_status(resp) 305 return AwdForensicsResponse.model_validate(resp.json()) 306 307 def grade_forensics(self, competition_id: str) -> AdminTaskInfo: 308 """§4.3.1's model half: judge every entrant's write-up, now. 309 310 Returns the **task**, not the readings: this is one model call 311 per entrant, so a field of a dozen is a minute of work. Poll 312 ``client.admin.tasks.get(id)``, then read :meth:`forensics`, 313 whose rows carry ``analysis`` once it has run. 314 315 ⚠️ **A press rather than a cadence**, unlike the exercise 316 grader: a GAUNTLET report is drafted over days and graded on a 317 quiet window, while a match *ends* and the write-up is judged 318 once against the document it finished with. 319 320 ⚠️ **It spends a model call per entrant every press**, and the 321 task reports ``skipped`` rather than refusing when this 322 deployment has no gateway — ``forensics()``'s 323 ``analysis_configured`` is what says so before you press. 324 325 ⚠️ The verdicts are about **depth, never accuracy**: with no 326 answer key a model cannot say whether a stated root cause is 327 right, only whether the report states one specifically. A row 328 reading ``root_cause: specific`` is not the platform agreeing 329 with the defender. 330 """ 331 resp = self._http.request( 332 "POST", f"/admin/competitions/{competition_id}/awd/forensics/grade" 333 ) 334 _raise_for_status(resp) 335 return AdminTaskInfo.model_validate(resp.json()) 336 337 def provision(self, competition_id: str) -> AdminTaskInfo: 338 """Start a gamebox for every team that lacks one. 339 340 Returns the **task**, not the boxes: a fleet launch is minutes 341 of work at event scale. Poll ``client.admin.tasks.get(id)`` (or 342 ``.logs``, which names every team that failed) for the outcome. 343 Idempotent — re-running skips the teams already holding a box. 344 """ 345 resp = self._http.request("POST", f"/admin/competitions/{competition_id}/awd/provision") 346 _raise_for_status(resp) 347 return AdminTaskInfo.model_validate(resp.json()) 348 349 def start_anyway(self, competition_id: str) -> AwdMatchInfo: 350 """Start the match although its arena was not measured whole (#1713). 351 352 The provisioning run checks every link between the arena's nodes 353 before kickoff; when that check is not ``whole`` the tick settles 354 no round until the arena is re-checked whole or somebody presses 355 this. Records who and when as an audit fact. Refused with 409 356 ``awd_kickoff_not_held`` when nothing is being held. 357 """ 358 resp = self._http.request( 359 "POST", f"/admin/competitions/{competition_id}/awd/kickoff-override" 360 ) 361 _raise_for_status(resp) 362 return AwdMatchInfo.model_validate(resp.json()) 363 364 def teardown(self, competition_id: str) -> AdminTaskInfo: 365 """Stop every arena box of this match. Also a task. 366 367 Only this competition's ``lease="match"`` boxes: a player's own 368 on-demand instance is not part of the match, and no node is 369 wiped wholesale — the arena shares hosts with every other event 370 on the platform. 371 """ 372 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/boxes") 373 _raise_for_status(resp) 374 return AdminTaskInfo.model_validate(resp.json())
The organiser's half of a match: cadence, gamebox, budgets.
⚠️ The round number is derived from the clock, not stored, so
starts_at / tick_seconds / challenge_id are frozen once
the match has started — editing one would retroactively renumber
every round already settled and published. configure()
answers 409 awd_match_locked naming the fields, rather than
silently accepting a write that breaks the board.
There is deliberately no way to set last_tick_done: it is the
tick loop's bookmark, and moving it makes the loop re-settle rounds
the board has already published.
48 def get(self, competition_id: str) -> AwdMatchInfo: 49 """This competition's match. 404 if it runs none. 50 51 ``current_tick`` and ``started`` are computed per read — asking 52 again a round later gives a different answer with no write in 53 between, which is the point. 54 """ 55 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/match") 56 _raise_for_status(resp) 57 return AwdMatchInfo.model_validate(resp.json())
This competition's match. 404 if it runs none.
current_tick and started are computed per read — asking
again a round later gives a different answer with no write in
between, which is the point.
59 def configure( 60 self, 61 competition_id: str, 62 *, 63 tick_seconds: int, 64 tick_count: int, 65 challenge_id: str = "", 66 starts_at: datetime | None = None, 67 per_tick_budget: int | None = None, 68 total_budget: int | None = None, 69 attacker: str = AwdAttacker.NONE.value, 70 reference_victim: bool = False, 71 defence_mode: str = AwdDefenceMode.PATCH.value, 72 shell_exposure: str = AwdShellExposure.MANAGED.value, 73 open_shell_credential: str = AwdOpenShellCredential.OWNER_RANDOM.value, 74 scoring_params: Mapping[str, float] | None = None, 75 ) -> AwdMatchInfo: 76 """Create the match, or edit it. Idempotent on the competition. 77 78 ``challenge_id`` may be left empty while the match is being put 79 together — provisioning is what refuses without a gamebox, and 80 it refuses at the point an organiser can still act on it. 81 82 ⚠️ **This is a full replace, and every argument above is 83 therefore a value rather than an option.** The route writes 84 ``existing.model_copy(update=body)``, so an argument left at its 85 default is *written*, not skipped: calling this to change the 86 cadence of an armed match disarms the platform attacker and 87 discards the organiser's scoring knobs. Read the row and pass it 88 back if you mean "edit one field" — which is what ``ctfy-admin 89 admin awd set`` does, and why it exists as more than a wrapper. 90 91 Kept as a replace on purpose: a caller that means *disarm this* 92 has to be able to say so, and a library that quietly reads the 93 current row before writing turns one request into a 94 read-then-write race whose loser is somebody else's edit. 95 """ 96 body: dict[str, object] = { 97 "challenge_id": challenge_id, 98 "starts_at": starts_at.isoformat() if starts_at else None, 99 "tick_seconds": tick_seconds, 100 "tick_count": tick_count, 101 "per_tick_budget": per_tick_budget, 102 "total_budget": total_budget, 103 "attacker": attacker, 104 "reference_victim": reference_victim, 105 "defence_mode": defence_mode, 106 "shell_exposure": shell_exposure, 107 "open_shell_credential": open_shell_credential, 108 "scoring_params": dict(scoring_params or {}), 109 } 110 resp = self._http.request( 111 "PUT", f"/admin/competitions/{competition_id}/awd/match", json=body 112 ) 113 _raise_for_status(resp) 114 return AwdMatchInfo.model_validate(resp.json())
Create the match, or edit it. Idempotent on the competition.
challenge_id may be left empty while the match is being put
together — provisioning is what refuses without a gamebox, and
it refuses at the point an organiser can still act on it.
⚠️ This is a full replace, and every argument above is
therefore a value rather than an option. The route writes
existing.model_copy(update=body), so an argument left at its
default is written, not skipped: calling this to change the
cadence of an armed match disarms the platform attacker and
discards the organiser's scoring knobs. Read the row and pass it
back if you mean "edit one field" — which is what ctfy-admin
admin awd set does, and why it exists as more than a wrapper.
Kept as a replace on purpose: a caller that means disarm this has to be able to say so, and a library that quietly reads the current row before writing turns one request into a read-then-write race whose loser is somebody else's edit.
116 def delete(self, competition_id: str) -> None: 117 """Remove a match that never started and holds no boxes. 118 119 Refused once the first round has opened — its scores are 120 published and keyed by round, so the configuration they belong 121 to has to stay. Lowering ``tick_count`` is how a running match 122 is ended. 123 """ 124 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/match") 125 _raise_for_status(resp)
Remove a match that never started and holds no boxes.
Refused once the first round has opened — its scores are
published and keyed by round, so the configuration they belong
to has to stay. Lowering tick_count is how a running match
is ended.
127 def plan(self, competition_id: str) -> AwdProvisionPlanInfo: 128 """What opening the arena would do, without doing it. 129 130 409 ``awd_provision_refused`` when the match cannot be 131 provisioned at all — no gamebox, no teams, or a match that has 132 already started (§4.5 fixes the box supply at kickoff). 133 """ 134 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/plan") 135 _raise_for_status(resp) 136 return AwdProvisionPlanInfo.model_validate(resp.json())
What opening the arena would do, without doing it.
409 awd_provision_refused when the match cannot be
provisioned at all — no gamebox, no teams, or a match that has
already started (§4.5 fixes the box supply at kickoff).
138 def arena_peers(self, competition_id: str) -> AwdArenaPeersResponse: 139 """Who is on this match's arena, and whether their tunnel carries. 140 141 One row per person: the worker their tunnel terminates on, the 142 address they hold, and what the last round's probe found — no 143 handshake, a handshake with no ping, or small packets through and 144 full-size ones not, which are three different repairs on three 145 different machines. 146 147 ``link`` is ``None`` when no round has probed that peer yet, 148 which is deliberately not the same as a peer that is down. 149 150 Carries no config and no key: knowing *that* somebody is 151 connected is the job, and the private half is stored nowhere. 152 """ 153 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/vpn-peers") 154 _raise_for_status(resp) 155 return AwdArenaPeersResponse.model_validate(resp.json())
Who is on this match's arena, and whether their tunnel carries.
One row per person: the worker their tunnel terminates on, the address they hold, and what the last round's probe found — no handshake, a handshake with no ping, or small packets through and full-size ones not, which are three different repairs on three different machines.
link is None when no round has probed that peer yet,
which is deliberately not the same as a peer that is down.
Carries no config and no key: knowing that somebody is connected is the job, and the private half is stored nowhere.
157 def corroboration(self, competition_id: str, tick: int) -> AwdCorroborationResponse: 158 """Which of a round's captures the victims' own traffic backs up. 159 160 A flag is bound to ``(victim, service, tick)`` and not to whoever 161 went and got it, so passing one between teams scores — a 162 structural hole in AWD. What a platform with packet capture adds 163 is cost: an attacker who really took the flag was on the box, so 164 their arena address is in that box's capture. 165 166 **Reports, never rules.** No score moves and no flag is refused; 167 the submission path does not call this. ``verdict`` is 168 three-valued and only ``absent`` says anything about the 169 attacker — ``unreadable`` is every way of not finding somebody 170 that is not evidence *about* them (no sidecar on their box, a 171 box that recorded nothing because its service is not HTTP, or an 172 attacking team with no arena address on record at all). 173 174 ``totals`` carries every verdict at zero, so a round that is 175 entirely unreadable says so rather than reading as "no 176 findings". 177 """ 178 resp = self._http.request( 179 "GET", 180 f"/admin/competitions/{competition_id}/awd/corroboration", 181 params={"tick": tick}, 182 ) 183 _raise_for_status(resp) 184 return AwdCorroborationResponse.model_validate(resp.json())
Which of a round's captures the victims' own traffic backs up.
A flag is bound to (victim, service, tick) and not to whoever
went and got it, so passing one between teams scores — a
structural hole in AWD. What a platform with packet capture adds
is cost: an attacker who really took the flag was on the box, so
their arena address is in that box's capture.
Reports, never rules. No score moves and no flag is refused;
the submission path does not call this. verdict is
three-valued and only absent says anything about the
attacker — unreadable is every way of not finding somebody
that is not evidence about them (no sidecar on their box, a
box that recorded nothing because its service is not HTTP, or an
attacking team with no arena address on record at all).
totals carries every verdict at zero, so a round that is
entirely unreadable says so rather than reading as "no
findings".
186 def defence_anchor(self, competition_id: str) -> AwdDefenceAnchorResponse: 187 """Who blocked the reference exploit, and over how many rounds. 188 189 A model-vs-model ranking is **relative** — a weak field flatters 190 everybody on it — so it does not compare across matches, which 191 for an evaluation product is the whole problem. The reference 192 attacker is the fixed half: every entrant, in every match, meets 193 the same ``solution/exploit.py``, so "held N of the M rounds it 194 ran" carries the same meaning everywhere. 195 196 ⚠️ **M is not the round count.** ``held`` is counted only over 197 rounds in which the exploit actually executed: a round the 198 platform never attacked and a round the defender held leave the 199 same absence, so a scale derived from captures alone credits an 200 entrant for every round the platform failed to attack them. 201 ``not_attempted`` is how much of the match went unmeasured, and 202 it is the platform's fault rather than theirs. 203 204 ``hold_rate`` is ``None`` — never ``0.0`` — for an entrant with 205 no attempted rounds: a zero would assert they were breached 206 every round. 207 208 ``armed`` is ``False`` for a match that runs no reference 209 attacker at all, which is a different thing from an attacker 210 that found nothing. 211 """ 212 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/anchor") 213 _raise_for_status(resp) 214 return AwdDefenceAnchorResponse.model_validate(resp.json())
Who blocked the reference exploit, and over how many rounds.
A model-vs-model ranking is relative — a weak field flatters
everybody on it — so it does not compare across matches, which
for an evaluation product is the whole problem. The reference
attacker is the fixed half: every entrant, in every match, meets
the same solution/exploit.py, so "held N of the M rounds it
ran" carries the same meaning everywhere.
⚠️ M is not the round count. held is counted only over
rounds in which the exploit actually executed: a round the
platform never attacked and a round the defender held leave the
same absence, so a scale derived from captures alone credits an
entrant for every round the platform failed to attack them.
not_attempted is how much of the match went unmeasured, and
it is the platform's fault rather than theirs.
hold_rate is None — never 0.0 — for an entrant with
no attempted rounds: a zero would assert they were breached
every round.
armed is False for a match that runs no reference
attacker at all, which is a different thing from an attacker
that found nothing.
216 def sla_history(self, competition_id: str, *, team_id: str = "") -> AwdSlaHistoryResponse: 217 """When in each round this match's SLA probes actually landed. 218 219 A check row records *which* round and, until recently, nothing 220 about *when in it* — so "was my box probed before I had a chance 221 to repair it", the question a disputed deduction turns on, could 222 only be answered by reading the tick loop. Each row here carries 223 the seconds from its own round's opening, and ``spread_s`` is how 224 much later the last box was asked than the first. 225 226 ``offset_s`` is ``None`` for a row written before the column 227 existed, and for a match with no kickoff to measure from. It is 228 never ``0.0`` for those: zero means the probe landed on the 229 round's opening second. 230 """ 231 params: dict[str, str] = {} 232 if team_id: 233 params["team_id"] = team_id 234 resp = self._http.request( 235 "GET", f"/admin/competitions/{competition_id}/awd/sla-history", params=params or None 236 ) 237 return AwdSlaHistoryResponse.model_validate(resp.json())
When in each round this match's SLA probes actually landed.
A check row records which round and, until recently, nothing
about when in it — so "was my box probed before I had a chance
to repair it", the question a disputed deduction turns on, could
only be answered by reading the tick loop. Each row here carries
the seconds from its own round's opening, and spread_s is how
much later the last box was asked than the first.
offset_s is None for a row written before the column
existed, and for a match with no kickoff to measure from. It is
never 0.0 for those: zero means the probe landed on the
round's opening second.
239 def attack_anchor(self, competition_id: str) -> AwdAttackAnchorResponse: 240 """Who broke the reference victim, and over how many rounds. 241 242 The mirror of :meth:`defence_anchor`. That one fixes the 243 **attacker** every entrant meets; this one fixes the 244 **victim** — §16.6's free target, one unpatched box owned by 245 nobody and never defended — so "broke a known-vulnerable box in 246 N of the M rounds it was up" carries the same meaning in every 247 match, where a place on the relative board is a statement about 248 the field an entrant happened to draw. 249 250 ⚠️ **M is the free box's own SLA record, not the round count.** 251 Nobody can break a box that was down, so debiting every entrant 252 for a round the instrument spent broken makes the number a 253 reading of our uptime rather than their attack. ``offered`` is 254 that denominator and is **match-wide**: every row carries the 255 same value, which is what makes two entrants comparable. 256 257 ``breach_rate`` is ``None`` — never ``0.0`` — when nothing was 258 offered, and ``first_breach_tick`` is ``None`` — never ``-1`` 259 and never ``0``, since round 0 is a real round — for an entrant 260 who never broke it. 261 262 ``armed`` is ``False`` for a match that runs no reference victim 263 at all, which is a different thing from a free box nobody broke. 264 """ 265 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/attack-anchor") 266 _raise_for_status(resp) 267 return AwdAttackAnchorResponse.model_validate(resp.json())
Who broke the reference victim, and over how many rounds.
The mirror of defence_anchor(). That one fixes the
attacker every entrant meets; this one fixes the
victim — §16.6's free target, one unpatched box owned by
nobody and never defended — so "broke a known-vulnerable box in
N of the M rounds it was up" carries the same meaning in every
match, where a place on the relative board is a statement about
the field an entrant happened to draw.
⚠️ M is the free box's own SLA record, not the round count.
Nobody can break a box that was down, so debiting every entrant
for a round the instrument spent broken makes the number a
reading of our uptime rather than their attack. offered is
that denominator and is match-wide: every row carries the
same value, which is what makes two entrants comparable.
breach_rate is None — never 0.0 — when nothing was
offered, and first_breach_tick is None — never -1
and never 0, since round 0 is a real round — for an entrant
who never broke it.
armed is False for a match that runs no reference victim
at all, which is a different thing from a free box nobody broke.
269 def forensics(self, competition_id: str) -> AwdForensicsResponse: 270 """§4.3.1's machine half: who found the attacker in their own box. 271 272 The platform's agent attacker carries a marker per ``(team, 273 round)`` into every gamebox. A defender who instrumented their 274 box can locate where that round's attacker touched it and quote 275 the value back in their engagement report; this is how much of 276 what reached them they found. It is the shallow half on 277 purpose — "did you keep a log and did you read it" — and the 278 model-judged half is what stops a forensic range being a grep 279 exercise. 280 281 ⚠️ **``offered`` is not the round count.** `defence_anchor`'s 282 rule through a third door: the attacker carrying these markers 283 is an LLM and therefore random, so a round it never landed in is 284 a round the *platform* failed to attack. Charging the team for 285 it would make the number a reading of our own uptime wearing 286 their name. 287 288 ``discovery_rate`` is ``None`` — never ``0.0`` — for an entrant 289 nothing reached. 290 291 ⚠️ **``armed`` reads whether the attacker carries an environment 292 at all**, which is a narrower question than "is there an 293 attacker": a reference-exploit match attacks every round and 294 mints no marker, because `build_attack_argv` passes no 295 environment and EXPLOIT007's guarantee rests on that. Every row 296 would then read ``offered: 0``, byte-identical to a field the 297 attacker reached and nobody traced. 298 299 ``reported`` annotates rather than decides: a team with no 300 report and a team whose report quotes nothing score the same, 301 and correctly — what differs is the repair. 302 """ 303 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/forensics") 304 _raise_for_status(resp) 305 return AwdForensicsResponse.model_validate(resp.json())
§4.3.1's machine half: who found the attacker in their own box.
The platform's agent attacker carries a marker per (team,
round) into every gamebox. A defender who instrumented their
box can locate where that round's attacker touched it and quote
the value back in their engagement report; this is how much of
what reached them they found. It is the shallow half on
purpose — "did you keep a log and did you read it" — and the
model-judged half is what stops a forensic range being a grep
exercise.
⚠️ offered is not the round count. defence_anchor's
rule through a third door: the attacker carrying these markers
is an LLM and therefore random, so a round it never landed in is
a round the platform failed to attack. Charging the team for
it would make the number a reading of our own uptime wearing
their name.
discovery_rate is None — never 0.0 — for an entrant
nothing reached.
⚠️ armed reads whether the attacker carries an environment
at all, which is a narrower question than "is there an
attacker": a reference-exploit match attacks every round and
mints no marker, because build_attack_argv passes no
environment and EXPLOIT007's guarantee rests on that. Every row
would then read offered: 0, byte-identical to a field the
attacker reached and nobody traced.
reported annotates rather than decides: a team with no
report and a team whose report quotes nothing score the same,
and correctly — what differs is the repair.
307 def grade_forensics(self, competition_id: str) -> AdminTaskInfo: 308 """§4.3.1's model half: judge every entrant's write-up, now. 309 310 Returns the **task**, not the readings: this is one model call 311 per entrant, so a field of a dozen is a minute of work. Poll 312 ``client.admin.tasks.get(id)``, then read :meth:`forensics`, 313 whose rows carry ``analysis`` once it has run. 314 315 ⚠️ **A press rather than a cadence**, unlike the exercise 316 grader: a GAUNTLET report is drafted over days and graded on a 317 quiet window, while a match *ends* and the write-up is judged 318 once against the document it finished with. 319 320 ⚠️ **It spends a model call per entrant every press**, and the 321 task reports ``skipped`` rather than refusing when this 322 deployment has no gateway — ``forensics()``'s 323 ``analysis_configured`` is what says so before you press. 324 325 ⚠️ The verdicts are about **depth, never accuracy**: with no 326 answer key a model cannot say whether a stated root cause is 327 right, only whether the report states one specifically. A row 328 reading ``root_cause: specific`` is not the platform agreeing 329 with the defender. 330 """ 331 resp = self._http.request( 332 "POST", f"/admin/competitions/{competition_id}/awd/forensics/grade" 333 ) 334 _raise_for_status(resp) 335 return AdminTaskInfo.model_validate(resp.json())
§4.3.1's model half: judge every entrant's write-up, now.
Returns the task, not the readings: this is one model call
per entrant, so a field of a dozen is a minute of work. Poll
client.admin.tasks.get(id), then read forensics(),
whose rows carry analysis once it has run.
⚠️ A press rather than a cadence, unlike the exercise grader: a GAUNTLET report is drafted over days and graded on a quiet window, while a match ends and the write-up is judged once against the document it finished with.
⚠️ It spends a model call per entrant every press, and the
task reports skipped rather than refusing when this
deployment has no gateway — forensics()'s
analysis_configured is what says so before you press.
⚠️ The verdicts are about depth, never accuracy: with no
answer key a model cannot say whether a stated root cause is
right, only whether the report states one specifically. A row
reading root_cause: specific is not the platform agreeing
with the defender.
337 def provision(self, competition_id: str) -> AdminTaskInfo: 338 """Start a gamebox for every team that lacks one. 339 340 Returns the **task**, not the boxes: a fleet launch is minutes 341 of work at event scale. Poll ``client.admin.tasks.get(id)`` (or 342 ``.logs``, which names every team that failed) for the outcome. 343 Idempotent — re-running skips the teams already holding a box. 344 """ 345 resp = self._http.request("POST", f"/admin/competitions/{competition_id}/awd/provision") 346 _raise_for_status(resp) 347 return AdminTaskInfo.model_validate(resp.json())
Start a gamebox for every team that lacks one.
Returns the task, not the boxes: a fleet launch is minutes
of work at event scale. Poll client.admin.tasks.get(id) (or
.logs, which names every team that failed) for the outcome.
Idempotent — re-running skips the teams already holding a box.
349 def start_anyway(self, competition_id: str) -> AwdMatchInfo: 350 """Start the match although its arena was not measured whole (#1713). 351 352 The provisioning run checks every link between the arena's nodes 353 before kickoff; when that check is not ``whole`` the tick settles 354 no round until the arena is re-checked whole or somebody presses 355 this. Records who and when as an audit fact. Refused with 409 356 ``awd_kickoff_not_held`` when nothing is being held. 357 """ 358 resp = self._http.request( 359 "POST", f"/admin/competitions/{competition_id}/awd/kickoff-override" 360 ) 361 _raise_for_status(resp) 362 return AwdMatchInfo.model_validate(resp.json())
Start the match although its arena was not measured whole (#1713).
The provisioning run checks every link between the arena's nodes
before kickoff; when that check is not whole the tick settles
no round until the arena is re-checked whole or somebody presses
this. Records who and when as an audit fact. Refused with 409
awd_kickoff_not_held when nothing is being held.
364 def teardown(self, competition_id: str) -> AdminTaskInfo: 365 """Stop every arena box of this match. Also a task. 366 367 Only this competition's ``lease="match"`` boxes: a player's own 368 on-demand instance is not part of the match, and no node is 369 wiped wholesale — the arena shares hosts with every other event 370 on the platform. 371 """ 372 resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/boxes") 373 _raise_for_status(resp) 374 return AdminTaskInfo.model_validate(resp.json())
Stop every arena box of this match. Also a task.
Only this competition's lease="match" boxes: a player's own
on-demand instance is not part of the match, and no node is
wiped wholesale — the arena shares hosts with every other event
on the platform.