ctfy.sdk.resources.awd
client.awd — classic-AWD attack submissions.
1"""``client.awd`` — classic-AWD attack submissions.""" 2 3from __future__ import annotations 4 5import builtins 6 7from ctfy.sdk._helpers import _raise_for_status 8from ctfy.sdk.base import BaseHttpClient 9from ctfy.server.models import ( 10 AwdAttackMatrixResponse, 11 AwdBoardResponse, 12 AwdDefenceTimelineResponse, 13 AwdEventsResponse, 14 AwdFlagSubmitResponse, 15 AwdMatchStatus, 16 AwdRoundHistoryResponse, 17 AwdTargetsResponse, 18 AwdVpnPeer, 19 AwdVpnStatus, 20 PublicTranscriptResponse, 21 SshCredential, 22) 23 24 25class AwdResource: 26 """A classic-AWD match: submit captured flags, read the board. 27 28 ⚠️ **Submit in batches.** A round hands a working exploit hundreds of 29 flags, and one request per flag is not a slow client — it is an 30 unplayable event on a single-process platform. The rate limit counts 31 requests, so a team's whole round fits in one or two calls. 32 """ 33 34 def __init__(self, http: BaseHttpClient) -> None: 35 self._http = http 36 37 def submit_flags(self, competition_id: str, flags: builtins.list[str]) -> AwdFlagSubmitResponse: 38 """Submit captured flags; get one verdict per value, in order. 39 40 Results are **positional**: a value the caller cannot match back 41 (a typo, a truncated read) still occupies its slot, so a client 42 may zip the reply against what it sent. 43 44 Resubmitting is free — a capture is keyed by (attacker, victim, 45 service, round), so a retried batch reports ``already_captured`` 46 rather than scoring twice. 47 """ 48 resp = self._http.request( 49 "POST", 50 f"/competitions/{competition_id}/awd/flags", 51 json={"flags": flags}, 52 ) 53 _raise_for_status(resp) 54 return AwdFlagSubmitResponse.model_validate(resp.json()) 55 56 def board(self, competition_id: str, *, tick: int | None = None) -> AwdBoardResponse: 57 """One round's standings, plus each team's service matrix. 58 59 Defaults to the **last settled** round, not the one in progress: 60 settlement runs at a round's end, so the current round has no 61 scores and asking for it renders an empty board. Pass ``tick`` 62 for history — a settled round's rows never change again. 63 """ 64 params = {} if tick is None else {"tick": tick} 65 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/board", params=params) 66 _raise_for_status(resp) 67 return AwdBoardResponse.model_validate(resp.json()) 68 69 def history(self, competition_id: str, *, top: int = 12) -> AwdRoundHistoryResponse: 70 """Every settled round's standings — the match as a ladder. 71 72 A round-based match's time is discrete: scores exist only at 73 settlement and jump there. :meth:`board` answers "who is ahead 74 now"; this answers "who has been climbing", which one round 75 cannot. 76 77 ``rank`` is recomputed **per round**, so a crossing shows as a 78 crossing. ``top`` is a display depth, not an exclusion — the 79 teams it keeps carry their real place in the field, so a team 80 that was second reads 2 even when it is the only series returned. 81 """ 82 resp = self._http.request( 83 "GET", f"/competitions/{competition_id}/awd/history", params={"top": top} 84 ) 85 _raise_for_status(resp) 86 return AwdRoundHistoryResponse.model_validate(resp.json()) 87 88 def events(self, competition_id: str, *, limit: int = 50) -> AwdEventsResponse: 89 """Recent settled captures — the spectator "赛况" feed. 90 91 Newest first, **settled rounds only**: a capture in the round in 92 progress carries a flag still worth points, so it is never here. 93 Each row is tagged for the match-first capture (first blood) and a 94 victim's first breach; a platform anchor is labelled, never 95 counted. ``last_tick`` is ``-1`` before the first settlement, when 96 the list is legitimately empty. 97 """ 98 resp = self._http.request( 99 "GET", f"/competitions/{competition_id}/awd/events", params={"limit": limit} 100 ) 101 _raise_for_status(resp) 102 return AwdEventsResponse.model_validate(resp.json()) 103 104 def transcript(self, competition_id: str, call_id: str) -> PublicTranscriptResponse: 105 """One entered agent's public, scrubbed conversation (B2). 106 107 The spectator counterpart to the operator's transcript drawer, and 108 far more guarded: it returns only after the author's **second** 109 consent (``transcript_published_at``), never for an anonymous 110 entrant, and never while the flag the round captured is still 111 live — an organiser reads live, a spectator waits for the flag to 112 expire. ``build_public_transcript`` emits **only** the agent's own 113 turns, scrubbed; the system prompt and every tool result are 114 dropped structurally, so ``had_system`` / ``had_tool_result`` say 115 they existed without carrying a byte of them. 116 117 ``available`` is ``False`` when the run's body was never kept or 118 has been reclaimed — the row still resolves, so a spectator learns 119 the conversation is gone rather than meeting a 404. 120 """ 121 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/transcript/{call_id}") 122 _raise_for_status(resp) 123 return PublicTranscriptResponse.model_validate(resp.json()) 124 125 def attack_matrix( 126 self, 127 competition_id: str, 128 *, 129 at_tick: int | None = None, 130 through_tick: int | None = None, 131 ) -> AwdAttackMatrixResponse: 132 """Who has taken what from whom. 133 134 :meth:`board` carries `attack` as one number per team, which 135 cannot say *whose* box you broke or who keeps breaking yours. 136 This is the same rows and columns as the roster, one cell per 137 ordered pair. A pair that never captured is **absent** from 138 ``captures``, never a present zero. 139 140 With neither selector the grid is cumulative through the **last 141 settled round** — the running total. ``at_tick`` isolates one 142 round (a per-round slider), ``through_tick`` moves the cumulative 143 bound to a chosen round; passing both is refused (a matrix is one 144 round or a running total, never both). The response echoes 145 ``at_tick`` so a one-round fold is distinguishable from a 146 cumulative one stopping at the same round. 147 """ 148 params: dict[str, int] = {} 149 if at_tick is not None: 150 params["at_tick"] = at_tick 151 if through_tick is not None: 152 params["through_tick"] = through_tick 153 resp = self._http.request( 154 "GET", 155 f"/competitions/{competition_id}/awd/attack-matrix", 156 params=params or None, 157 ) 158 _raise_for_status(resp) 159 return AwdAttackMatrixResponse.model_validate(resp.json()) 160 161 def defence_timeline(self, competition_id: str) -> AwdDefenceTimelineResponse: 162 """The per-round "did they fix it?" verdict, one row per entrant. 163 164 :meth:`board` carries ``defence`` as one number and cannot say 165 *which rounds* a team held their live box with the service still 166 up. This emits the per-``(team, tick)`` verdict — ``fixed``, 167 ``not_fixed``, ``functionality_broken`` or ``inconclusive`` — for 168 the whole match. 169 170 SHELL-mode matches only for now: a ``patch``-mode match answers 171 ``applicable: false`` with no rows (the round signal lands in a 172 later phase). An entrant the reference exploit never reached is a 173 row with an empty ``cells`` list. 174 """ 175 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/defence-timeline") 176 _raise_for_status(resp) 177 return AwdDefenceTimelineResponse.model_validate(resp.json()) 178 179 def targets(self, competition_id: str) -> AwdTargetsResponse: 180 """Every gamebox in this match and the address it answers on. 181 182 The attack surface. A gamebox publishes no ports and the match 183 network is internal, so the address here is the only way to 184 reach one — there is no second place to look it up and no way to 185 derive it. 186 187 Your own box is in the list, marked ``is_own``: you need it to 188 know what to defend, and a flag taken from it is refused. 189 """ 190 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/targets") 191 _raise_for_status(resp) 192 return AwdTargetsResponse.model_validate(resp.json()) 193 194 def ssh_credential(self, competition_id: str) -> SshCredential: 195 """Mint a short-lived SSH certificate for **your own box in this 196 match**, addressed by the match rather than by an instance id. 197 198 ``instances.ssh_credential`` is the same credential and the same 199 authorisation — *(competition, team)* either way. What differs is 200 the key that finds the box: a match identifies a gamebox by its 201 **address** and its owning team, which is what :meth:`targets` 202 publishes and which carries no instance id at all. The id exists 203 only because the platform minted the box; §4.5 has the organiser 204 provision the arena before kickoff, so nobody who plays the match 205 created the thing they are being asked to name. 206 207 The credential expires in minutes — it only has to cover 208 *connecting*, and the session it opens outlives it. Ask again per 209 connect rather than caching one. 210 """ 211 resp = self._http.request("POST", f"/competitions/{competition_id}/awd/ssh") 212 _raise_for_status(resp) 213 return SshCredential.model_validate(resp.json()) 214 215 def arena_peer(self, competition_id: str) -> AwdVpnStatus: 216 """Whether this account already holds a peer, without rotating it. 217 218 ``vpn()`` cannot answer this: it is idempotent in its *address* 219 and deliberately not in its *key*, so calling it to find out 220 whether you are connected invalidates the ``wg-quick`` file you 221 already have. This is the read — ``peer`` is ``None`` when you 222 hold none, and the config it returns carries a placeholder where 223 the private half goes, because the platform stored none. 224 """ 225 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/vpn") 226 _raise_for_status(resp) 227 return AwdVpnStatus.model_validate(resp.json()) 228 229 def probe_arena_peer(self, competition_id: str) -> AwdVpnStatus: 230 """Ask the worker, now, whether this machine's tunnel is carrying. 231 232 Same three questions the round asks every peer each tick — 233 handshake, a small ping, a full-size one — but on demand, because 234 the moment you need them is the thirty seconds after 235 ``wg-quick up`` and the next round may be minutes away. 236 237 It probes your own peer and takes no address, so it cannot be 238 pointed anywhere else. Rate limited: it makes the platform dial 239 out to a worker node. 240 241 Refuses with ``awd_probe_unavailable`` when the arena's tunnel is 242 not up on that worker — which is deliberately not the same answer 243 as "your link is down", because the repair is on a different 244 machine. 245 """ 246 resp = self._http.request("POST", f"/competitions/{competition_id}/awd/vpn/probe") 247 _raise_for_status(resp) 248 return AwdVpnStatus.model_validate(resp.json()) 249 250 def vpn(self, competition_id: str, *, public_key: str = "") -> AwdVpnPeer: 251 """Put this machine on the arena, and get back a ``wg-quick`` file. 252 253 The classic arena is reachable over the tunnel and nowhere else — 254 a gamebox publishes no ports and the match network is an internal 255 bridge — so this is how anything that is not a platform-dispatched 256 container joins it. The peer terminates on the node holding your 257 own team's box. 258 259 **Two key-custody modes.** Pass *public_key* and this platform 260 never sees your private half; the file comes back with a 261 placeholder you fill in. Leave it empty and the platform mints a 262 pair and returns the private half **once** — ``config`` is then 263 complete, and ``private_key_included`` says which of the two you 264 got rather than making you scan the text for a placeholder. 265 266 Idempotent per person and safe to re-run: the address survives — 267 it is what the file on your machine already says — and only the 268 key rotates, which stops the previous file working. 269 """ 270 resp = self._http.request( 271 "POST", 272 f"/competitions/{competition_id}/awd/vpn", 273 json={"public_key": public_key}, 274 ) 275 _raise_for_status(resp) 276 return AwdVpnPeer.model_validate(resp.json()) 277 278 def revoke_vpn(self, competition_id: str) -> bool: 279 """Take this machine off the arena. ``True`` if there was a peer. 280 281 ⚠️ **It lands on the next round, not on this call.** The peer 282 lives on a worker node's WireGuard device and the platform 283 reaches it only through the tick's authoritative re-push; what 284 this does is remove the row the next push is built from. 285 """ 286 resp = self._http.request("DELETE", f"/competitions/{competition_id}/awd/vpn") 287 _raise_for_status(resp) 288 return bool(resp.json().get("revoked", False)) 289 290 def match(self, competition_id: str) -> AwdMatchStatus: 291 """The match's clock and rules — which round it is, and when it ends. 292 293 Public, like the board. ``tick_ends_at`` comes from the server 294 rather than being derived from ``starts_at`` + ``tick_seconds``: 295 the round arithmetic lives in exactly one place, and a client 296 that recomputed it would be the copy that disagrees. 297 298 ``current_tick`` is ``None`` before the first round and after the 299 last — not clamped, because "no round is in progress" is a real 300 state and a submission made then is judged against nothing. 301 """ 302 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/match") 303 _raise_for_status(resp) 304 return AwdMatchStatus.model_validate(resp.json())
26class AwdResource: 27 """A classic-AWD match: submit captured flags, read the board. 28 29 ⚠️ **Submit in batches.** A round hands a working exploit hundreds of 30 flags, and one request per flag is not a slow client — it is an 31 unplayable event on a single-process platform. The rate limit counts 32 requests, so a team's whole round fits in one or two calls. 33 """ 34 35 def __init__(self, http: BaseHttpClient) -> None: 36 self._http = http 37 38 def submit_flags(self, competition_id: str, flags: builtins.list[str]) -> AwdFlagSubmitResponse: 39 """Submit captured flags; get one verdict per value, in order. 40 41 Results are **positional**: a value the caller cannot match back 42 (a typo, a truncated read) still occupies its slot, so a client 43 may zip the reply against what it sent. 44 45 Resubmitting is free — a capture is keyed by (attacker, victim, 46 service, round), so a retried batch reports ``already_captured`` 47 rather than scoring twice. 48 """ 49 resp = self._http.request( 50 "POST", 51 f"/competitions/{competition_id}/awd/flags", 52 json={"flags": flags}, 53 ) 54 _raise_for_status(resp) 55 return AwdFlagSubmitResponse.model_validate(resp.json()) 56 57 def board(self, competition_id: str, *, tick: int | None = None) -> AwdBoardResponse: 58 """One round's standings, plus each team's service matrix. 59 60 Defaults to the **last settled** round, not the one in progress: 61 settlement runs at a round's end, so the current round has no 62 scores and asking for it renders an empty board. Pass ``tick`` 63 for history — a settled round's rows never change again. 64 """ 65 params = {} if tick is None else {"tick": tick} 66 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/board", params=params) 67 _raise_for_status(resp) 68 return AwdBoardResponse.model_validate(resp.json()) 69 70 def history(self, competition_id: str, *, top: int = 12) -> AwdRoundHistoryResponse: 71 """Every settled round's standings — the match as a ladder. 72 73 A round-based match's time is discrete: scores exist only at 74 settlement and jump there. :meth:`board` answers "who is ahead 75 now"; this answers "who has been climbing", which one round 76 cannot. 77 78 ``rank`` is recomputed **per round**, so a crossing shows as a 79 crossing. ``top`` is a display depth, not an exclusion — the 80 teams it keeps carry their real place in the field, so a team 81 that was second reads 2 even when it is the only series returned. 82 """ 83 resp = self._http.request( 84 "GET", f"/competitions/{competition_id}/awd/history", params={"top": top} 85 ) 86 _raise_for_status(resp) 87 return AwdRoundHistoryResponse.model_validate(resp.json()) 88 89 def events(self, competition_id: str, *, limit: int = 50) -> AwdEventsResponse: 90 """Recent settled captures — the spectator "赛况" feed. 91 92 Newest first, **settled rounds only**: a capture in the round in 93 progress carries a flag still worth points, so it is never here. 94 Each row is tagged for the match-first capture (first blood) and a 95 victim's first breach; a platform anchor is labelled, never 96 counted. ``last_tick`` is ``-1`` before the first settlement, when 97 the list is legitimately empty. 98 """ 99 resp = self._http.request( 100 "GET", f"/competitions/{competition_id}/awd/events", params={"limit": limit} 101 ) 102 _raise_for_status(resp) 103 return AwdEventsResponse.model_validate(resp.json()) 104 105 def transcript(self, competition_id: str, call_id: str) -> PublicTranscriptResponse: 106 """One entered agent's public, scrubbed conversation (B2). 107 108 The spectator counterpart to the operator's transcript drawer, and 109 far more guarded: it returns only after the author's **second** 110 consent (``transcript_published_at``), never for an anonymous 111 entrant, and never while the flag the round captured is still 112 live — an organiser reads live, a spectator waits for the flag to 113 expire. ``build_public_transcript`` emits **only** the agent's own 114 turns, scrubbed; the system prompt and every tool result are 115 dropped structurally, so ``had_system`` / ``had_tool_result`` say 116 they existed without carrying a byte of them. 117 118 ``available`` is ``False`` when the run's body was never kept or 119 has been reclaimed — the row still resolves, so a spectator learns 120 the conversation is gone rather than meeting a 404. 121 """ 122 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/transcript/{call_id}") 123 _raise_for_status(resp) 124 return PublicTranscriptResponse.model_validate(resp.json()) 125 126 def attack_matrix( 127 self, 128 competition_id: str, 129 *, 130 at_tick: int | None = None, 131 through_tick: int | None = None, 132 ) -> AwdAttackMatrixResponse: 133 """Who has taken what from whom. 134 135 :meth:`board` carries `attack` as one number per team, which 136 cannot say *whose* box you broke or who keeps breaking yours. 137 This is the same rows and columns as the roster, one cell per 138 ordered pair. A pair that never captured is **absent** from 139 ``captures``, never a present zero. 140 141 With neither selector the grid is cumulative through the **last 142 settled round** — the running total. ``at_tick`` isolates one 143 round (a per-round slider), ``through_tick`` moves the cumulative 144 bound to a chosen round; passing both is refused (a matrix is one 145 round or a running total, never both). The response echoes 146 ``at_tick`` so a one-round fold is distinguishable from a 147 cumulative one stopping at the same round. 148 """ 149 params: dict[str, int] = {} 150 if at_tick is not None: 151 params["at_tick"] = at_tick 152 if through_tick is not None: 153 params["through_tick"] = through_tick 154 resp = self._http.request( 155 "GET", 156 f"/competitions/{competition_id}/awd/attack-matrix", 157 params=params or None, 158 ) 159 _raise_for_status(resp) 160 return AwdAttackMatrixResponse.model_validate(resp.json()) 161 162 def defence_timeline(self, competition_id: str) -> AwdDefenceTimelineResponse: 163 """The per-round "did they fix it?" verdict, one row per entrant. 164 165 :meth:`board` carries ``defence`` as one number and cannot say 166 *which rounds* a team held their live box with the service still 167 up. This emits the per-``(team, tick)`` verdict — ``fixed``, 168 ``not_fixed``, ``functionality_broken`` or ``inconclusive`` — for 169 the whole match. 170 171 SHELL-mode matches only for now: a ``patch``-mode match answers 172 ``applicable: false`` with no rows (the round signal lands in a 173 later phase). An entrant the reference exploit never reached is a 174 row with an empty ``cells`` list. 175 """ 176 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/defence-timeline") 177 _raise_for_status(resp) 178 return AwdDefenceTimelineResponse.model_validate(resp.json()) 179 180 def targets(self, competition_id: str) -> AwdTargetsResponse: 181 """Every gamebox in this match and the address it answers on. 182 183 The attack surface. A gamebox publishes no ports and the match 184 network is internal, so the address here is the only way to 185 reach one — there is no second place to look it up and no way to 186 derive it. 187 188 Your own box is in the list, marked ``is_own``: you need it to 189 know what to defend, and a flag taken from it is refused. 190 """ 191 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/targets") 192 _raise_for_status(resp) 193 return AwdTargetsResponse.model_validate(resp.json()) 194 195 def ssh_credential(self, competition_id: str) -> SshCredential: 196 """Mint a short-lived SSH certificate for **your own box in this 197 match**, addressed by the match rather than by an instance id. 198 199 ``instances.ssh_credential`` is the same credential and the same 200 authorisation — *(competition, team)* either way. What differs is 201 the key that finds the box: a match identifies a gamebox by its 202 **address** and its owning team, which is what :meth:`targets` 203 publishes and which carries no instance id at all. The id exists 204 only because the platform minted the box; §4.5 has the organiser 205 provision the arena before kickoff, so nobody who plays the match 206 created the thing they are being asked to name. 207 208 The credential expires in minutes — it only has to cover 209 *connecting*, and the session it opens outlives it. Ask again per 210 connect rather than caching one. 211 """ 212 resp = self._http.request("POST", f"/competitions/{competition_id}/awd/ssh") 213 _raise_for_status(resp) 214 return SshCredential.model_validate(resp.json()) 215 216 def arena_peer(self, competition_id: str) -> AwdVpnStatus: 217 """Whether this account already holds a peer, without rotating it. 218 219 ``vpn()`` cannot answer this: it is idempotent in its *address* 220 and deliberately not in its *key*, so calling it to find out 221 whether you are connected invalidates the ``wg-quick`` file you 222 already have. This is the read — ``peer`` is ``None`` when you 223 hold none, and the config it returns carries a placeholder where 224 the private half goes, because the platform stored none. 225 """ 226 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/vpn") 227 _raise_for_status(resp) 228 return AwdVpnStatus.model_validate(resp.json()) 229 230 def probe_arena_peer(self, competition_id: str) -> AwdVpnStatus: 231 """Ask the worker, now, whether this machine's tunnel is carrying. 232 233 Same three questions the round asks every peer each tick — 234 handshake, a small ping, a full-size one — but on demand, because 235 the moment you need them is the thirty seconds after 236 ``wg-quick up`` and the next round may be minutes away. 237 238 It probes your own peer and takes no address, so it cannot be 239 pointed anywhere else. Rate limited: it makes the platform dial 240 out to a worker node. 241 242 Refuses with ``awd_probe_unavailable`` when the arena's tunnel is 243 not up on that worker — which is deliberately not the same answer 244 as "your link is down", because the repair is on a different 245 machine. 246 """ 247 resp = self._http.request("POST", f"/competitions/{competition_id}/awd/vpn/probe") 248 _raise_for_status(resp) 249 return AwdVpnStatus.model_validate(resp.json()) 250 251 def vpn(self, competition_id: str, *, public_key: str = "") -> AwdVpnPeer: 252 """Put this machine on the arena, and get back a ``wg-quick`` file. 253 254 The classic arena is reachable over the tunnel and nowhere else — 255 a gamebox publishes no ports and the match network is an internal 256 bridge — so this is how anything that is not a platform-dispatched 257 container joins it. The peer terminates on the node holding your 258 own team's box. 259 260 **Two key-custody modes.** Pass *public_key* and this platform 261 never sees your private half; the file comes back with a 262 placeholder you fill in. Leave it empty and the platform mints a 263 pair and returns the private half **once** — ``config`` is then 264 complete, and ``private_key_included`` says which of the two you 265 got rather than making you scan the text for a placeholder. 266 267 Idempotent per person and safe to re-run: the address survives — 268 it is what the file on your machine already says — and only the 269 key rotates, which stops the previous file working. 270 """ 271 resp = self._http.request( 272 "POST", 273 f"/competitions/{competition_id}/awd/vpn", 274 json={"public_key": public_key}, 275 ) 276 _raise_for_status(resp) 277 return AwdVpnPeer.model_validate(resp.json()) 278 279 def revoke_vpn(self, competition_id: str) -> bool: 280 """Take this machine off the arena. ``True`` if there was a peer. 281 282 ⚠️ **It lands on the next round, not on this call.** The peer 283 lives on a worker node's WireGuard device and the platform 284 reaches it only through the tick's authoritative re-push; what 285 this does is remove the row the next push is built from. 286 """ 287 resp = self._http.request("DELETE", f"/competitions/{competition_id}/awd/vpn") 288 _raise_for_status(resp) 289 return bool(resp.json().get("revoked", False)) 290 291 def match(self, competition_id: str) -> AwdMatchStatus: 292 """The match's clock and rules — which round it is, and when it ends. 293 294 Public, like the board. ``tick_ends_at`` comes from the server 295 rather than being derived from ``starts_at`` + ``tick_seconds``: 296 the round arithmetic lives in exactly one place, and a client 297 that recomputed it would be the copy that disagrees. 298 299 ``current_tick`` is ``None`` before the first round and after the 300 last — not clamped, because "no round is in progress" is a real 301 state and a submission made then is judged against nothing. 302 """ 303 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/match") 304 _raise_for_status(resp) 305 return AwdMatchStatus.model_validate(resp.json())
A classic-AWD match: submit captured flags, read the board.
⚠️ Submit in batches. A round hands a working exploit hundreds of flags, and one request per flag is not a slow client — it is an unplayable event on a single-process platform. The rate limit counts requests, so a team's whole round fits in one or two calls.
38 def submit_flags(self, competition_id: str, flags: builtins.list[str]) -> AwdFlagSubmitResponse: 39 """Submit captured flags; get one verdict per value, in order. 40 41 Results are **positional**: a value the caller cannot match back 42 (a typo, a truncated read) still occupies its slot, so a client 43 may zip the reply against what it sent. 44 45 Resubmitting is free — a capture is keyed by (attacker, victim, 46 service, round), so a retried batch reports ``already_captured`` 47 rather than scoring twice. 48 """ 49 resp = self._http.request( 50 "POST", 51 f"/competitions/{competition_id}/awd/flags", 52 json={"flags": flags}, 53 ) 54 _raise_for_status(resp) 55 return AwdFlagSubmitResponse.model_validate(resp.json())
Submit captured flags; get one verdict per value, in order.
Results are positional: a value the caller cannot match back (a typo, a truncated read) still occupies its slot, so a client may zip the reply against what it sent.
Resubmitting is free — a capture is keyed by (attacker, victim,
service, round), so a retried batch reports already_captured
rather than scoring twice.
57 def board(self, competition_id: str, *, tick: int | None = None) -> AwdBoardResponse: 58 """One round's standings, plus each team's service matrix. 59 60 Defaults to the **last settled** round, not the one in progress: 61 settlement runs at a round's end, so the current round has no 62 scores and asking for it renders an empty board. Pass ``tick`` 63 for history — a settled round's rows never change again. 64 """ 65 params = {} if tick is None else {"tick": tick} 66 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/board", params=params) 67 _raise_for_status(resp) 68 return AwdBoardResponse.model_validate(resp.json())
One round's standings, plus each team's service matrix.
Defaults to the last settled round, not the one in progress:
settlement runs at a round's end, so the current round has no
scores and asking for it renders an empty board. Pass tick
for history — a settled round's rows never change again.
70 def history(self, competition_id: str, *, top: int = 12) -> AwdRoundHistoryResponse: 71 """Every settled round's standings — the match as a ladder. 72 73 A round-based match's time is discrete: scores exist only at 74 settlement and jump there. :meth:`board` answers "who is ahead 75 now"; this answers "who has been climbing", which one round 76 cannot. 77 78 ``rank`` is recomputed **per round**, so a crossing shows as a 79 crossing. ``top`` is a display depth, not an exclusion — the 80 teams it keeps carry their real place in the field, so a team 81 that was second reads 2 even when it is the only series returned. 82 """ 83 resp = self._http.request( 84 "GET", f"/competitions/{competition_id}/awd/history", params={"top": top} 85 ) 86 _raise_for_status(resp) 87 return AwdRoundHistoryResponse.model_validate(resp.json())
Every settled round's standings — the match as a ladder.
A round-based match's time is discrete: scores exist only at
settlement and jump there. board() answers "who is ahead
now"; this answers "who has been climbing", which one round
cannot.
rank is recomputed per round, so a crossing shows as a
crossing. top is a display depth, not an exclusion — the
teams it keeps carry their real place in the field, so a team
that was second reads 2 even when it is the only series returned.
89 def events(self, competition_id: str, *, limit: int = 50) -> AwdEventsResponse: 90 """Recent settled captures — the spectator "赛况" feed. 91 92 Newest first, **settled rounds only**: a capture in the round in 93 progress carries a flag still worth points, so it is never here. 94 Each row is tagged for the match-first capture (first blood) and a 95 victim's first breach; a platform anchor is labelled, never 96 counted. ``last_tick`` is ``-1`` before the first settlement, when 97 the list is legitimately empty. 98 """ 99 resp = self._http.request( 100 "GET", f"/competitions/{competition_id}/awd/events", params={"limit": limit} 101 ) 102 _raise_for_status(resp) 103 return AwdEventsResponse.model_validate(resp.json())
Recent settled captures — the spectator "赛况" feed.
Newest first, settled rounds only: a capture in the round in
progress carries a flag still worth points, so it is never here.
Each row is tagged for the match-first capture (first blood) and a
victim's first breach; a platform anchor is labelled, never
counted. last_tick is -1 before the first settlement, when
the list is legitimately empty.
105 def transcript(self, competition_id: str, call_id: str) -> PublicTranscriptResponse: 106 """One entered agent's public, scrubbed conversation (B2). 107 108 The spectator counterpart to the operator's transcript drawer, and 109 far more guarded: it returns only after the author's **second** 110 consent (``transcript_published_at``), never for an anonymous 111 entrant, and never while the flag the round captured is still 112 live — an organiser reads live, a spectator waits for the flag to 113 expire. ``build_public_transcript`` emits **only** the agent's own 114 turns, scrubbed; the system prompt and every tool result are 115 dropped structurally, so ``had_system`` / ``had_tool_result`` say 116 they existed without carrying a byte of them. 117 118 ``available`` is ``False`` when the run's body was never kept or 119 has been reclaimed — the row still resolves, so a spectator learns 120 the conversation is gone rather than meeting a 404. 121 """ 122 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/transcript/{call_id}") 123 _raise_for_status(resp) 124 return PublicTranscriptResponse.model_validate(resp.json())
One entered agent's public, scrubbed conversation (B2).
The spectator counterpart to the operator's transcript drawer, and
far more guarded: it returns only after the author's second
consent (transcript_published_at), never for an anonymous
entrant, and never while the flag the round captured is still
live — an organiser reads live, a spectator waits for the flag to
expire. build_public_transcript emits only the agent's own
turns, scrubbed; the system prompt and every tool result are
dropped structurally, so had_system / had_tool_result say
they existed without carrying a byte of them.
available is False when the run's body was never kept or
has been reclaimed — the row still resolves, so a spectator learns
the conversation is gone rather than meeting a 404.
126 def attack_matrix( 127 self, 128 competition_id: str, 129 *, 130 at_tick: int | None = None, 131 through_tick: int | None = None, 132 ) -> AwdAttackMatrixResponse: 133 """Who has taken what from whom. 134 135 :meth:`board` carries `attack` as one number per team, which 136 cannot say *whose* box you broke or who keeps breaking yours. 137 This is the same rows and columns as the roster, one cell per 138 ordered pair. A pair that never captured is **absent** from 139 ``captures``, never a present zero. 140 141 With neither selector the grid is cumulative through the **last 142 settled round** — the running total. ``at_tick`` isolates one 143 round (a per-round slider), ``through_tick`` moves the cumulative 144 bound to a chosen round; passing both is refused (a matrix is one 145 round or a running total, never both). The response echoes 146 ``at_tick`` so a one-round fold is distinguishable from a 147 cumulative one stopping at the same round. 148 """ 149 params: dict[str, int] = {} 150 if at_tick is not None: 151 params["at_tick"] = at_tick 152 if through_tick is not None: 153 params["through_tick"] = through_tick 154 resp = self._http.request( 155 "GET", 156 f"/competitions/{competition_id}/awd/attack-matrix", 157 params=params or None, 158 ) 159 _raise_for_status(resp) 160 return AwdAttackMatrixResponse.model_validate(resp.json())
Who has taken what from whom.
board() carries attack as one number per team, which
cannot say whose box you broke or who keeps breaking yours.
This is the same rows and columns as the roster, one cell per
ordered pair. A pair that never captured is absent from
captures, never a present zero.
With neither selector the grid is cumulative through the last
settled round — the running total. at_tick isolates one
round (a per-round slider), through_tick moves the cumulative
bound to a chosen round; passing both is refused (a matrix is one
round or a running total, never both). The response echoes
at_tick so a one-round fold is distinguishable from a
cumulative one stopping at the same round.
162 def defence_timeline(self, competition_id: str) -> AwdDefenceTimelineResponse: 163 """The per-round "did they fix it?" verdict, one row per entrant. 164 165 :meth:`board` carries ``defence`` as one number and cannot say 166 *which rounds* a team held their live box with the service still 167 up. This emits the per-``(team, tick)`` verdict — ``fixed``, 168 ``not_fixed``, ``functionality_broken`` or ``inconclusive`` — for 169 the whole match. 170 171 SHELL-mode matches only for now: a ``patch``-mode match answers 172 ``applicable: false`` with no rows (the round signal lands in a 173 later phase). An entrant the reference exploit never reached is a 174 row with an empty ``cells`` list. 175 """ 176 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/defence-timeline") 177 _raise_for_status(resp) 178 return AwdDefenceTimelineResponse.model_validate(resp.json())
The per-round "did they fix it?" verdict, one row per entrant.
board() carries defence as one number and cannot say
which rounds a team held their live box with the service still
up. This emits the per-(team, tick) verdict — fixed,
not_fixed, functionality_broken or inconclusive — for
the whole match.
SHELL-mode matches only for now: a patch-mode match answers
applicable: false with no rows (the round signal lands in a
later phase). An entrant the reference exploit never reached is a
row with an empty cells list.
180 def targets(self, competition_id: str) -> AwdTargetsResponse: 181 """Every gamebox in this match and the address it answers on. 182 183 The attack surface. A gamebox publishes no ports and the match 184 network is internal, so the address here is the only way to 185 reach one — there is no second place to look it up and no way to 186 derive it. 187 188 Your own box is in the list, marked ``is_own``: you need it to 189 know what to defend, and a flag taken from it is refused. 190 """ 191 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/targets") 192 _raise_for_status(resp) 193 return AwdTargetsResponse.model_validate(resp.json())
Every gamebox in this match and the address it answers on.
The attack surface. A gamebox publishes no ports and the match network is internal, so the address here is the only way to reach one — there is no second place to look it up and no way to derive it.
Your own box is in the list, marked is_own: you need it to
know what to defend, and a flag taken from it is refused.
195 def ssh_credential(self, competition_id: str) -> SshCredential: 196 """Mint a short-lived SSH certificate for **your own box in this 197 match**, addressed by the match rather than by an instance id. 198 199 ``instances.ssh_credential`` is the same credential and the same 200 authorisation — *(competition, team)* either way. What differs is 201 the key that finds the box: a match identifies a gamebox by its 202 **address** and its owning team, which is what :meth:`targets` 203 publishes and which carries no instance id at all. The id exists 204 only because the platform minted the box; §4.5 has the organiser 205 provision the arena before kickoff, so nobody who plays the match 206 created the thing they are being asked to name. 207 208 The credential expires in minutes — it only has to cover 209 *connecting*, and the session it opens outlives it. Ask again per 210 connect rather than caching one. 211 """ 212 resp = self._http.request("POST", f"/competitions/{competition_id}/awd/ssh") 213 _raise_for_status(resp) 214 return SshCredential.model_validate(resp.json())
Mint a short-lived SSH certificate for your own box in this match, addressed by the match rather than by an instance id.
instances.ssh_credential is the same credential and the same
authorisation — (competition, team) either way. What differs is
the key that finds the box: a match identifies a gamebox by its
address and its owning team, which is what targets()
publishes and which carries no instance id at all. The id exists
only because the platform minted the box; §4.5 has the organiser
provision the arena before kickoff, so nobody who plays the match
created the thing they are being asked to name.
The credential expires in minutes — it only has to cover connecting, and the session it opens outlives it. Ask again per connect rather than caching one.
216 def arena_peer(self, competition_id: str) -> AwdVpnStatus: 217 """Whether this account already holds a peer, without rotating it. 218 219 ``vpn()`` cannot answer this: it is idempotent in its *address* 220 and deliberately not in its *key*, so calling it to find out 221 whether you are connected invalidates the ``wg-quick`` file you 222 already have. This is the read — ``peer`` is ``None`` when you 223 hold none, and the config it returns carries a placeholder where 224 the private half goes, because the platform stored none. 225 """ 226 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/vpn") 227 _raise_for_status(resp) 228 return AwdVpnStatus.model_validate(resp.json())
Whether this account already holds a peer, without rotating it.
vpn() cannot answer this: it is idempotent in its address
and deliberately not in its key, so calling it to find out
whether you are connected invalidates the wg-quick file you
already have. This is the read — peer is None when you
hold none, and the config it returns carries a placeholder where
the private half goes, because the platform stored none.
230 def probe_arena_peer(self, competition_id: str) -> AwdVpnStatus: 231 """Ask the worker, now, whether this machine's tunnel is carrying. 232 233 Same three questions the round asks every peer each tick — 234 handshake, a small ping, a full-size one — but on demand, because 235 the moment you need them is the thirty seconds after 236 ``wg-quick up`` and the next round may be minutes away. 237 238 It probes your own peer and takes no address, so it cannot be 239 pointed anywhere else. Rate limited: it makes the platform dial 240 out to a worker node. 241 242 Refuses with ``awd_probe_unavailable`` when the arena's tunnel is 243 not up on that worker — which is deliberately not the same answer 244 as "your link is down", because the repair is on a different 245 machine. 246 """ 247 resp = self._http.request("POST", f"/competitions/{competition_id}/awd/vpn/probe") 248 _raise_for_status(resp) 249 return AwdVpnStatus.model_validate(resp.json())
Ask the worker, now, whether this machine's tunnel is carrying.
Same three questions the round asks every peer each tick —
handshake, a small ping, a full-size one — but on demand, because
the moment you need them is the thirty seconds after
wg-quick up and the next round may be minutes away.
It probes your own peer and takes no address, so it cannot be pointed anywhere else. Rate limited: it makes the platform dial out to a worker node.
Refuses with awd_probe_unavailable when the arena's tunnel is
not up on that worker — which is deliberately not the same answer
as "your link is down", because the repair is on a different
machine.
251 def vpn(self, competition_id: str, *, public_key: str = "") -> AwdVpnPeer: 252 """Put this machine on the arena, and get back a ``wg-quick`` file. 253 254 The classic arena is reachable over the tunnel and nowhere else — 255 a gamebox publishes no ports and the match network is an internal 256 bridge — so this is how anything that is not a platform-dispatched 257 container joins it. The peer terminates on the node holding your 258 own team's box. 259 260 **Two key-custody modes.** Pass *public_key* and this platform 261 never sees your private half; the file comes back with a 262 placeholder you fill in. Leave it empty and the platform mints a 263 pair and returns the private half **once** — ``config`` is then 264 complete, and ``private_key_included`` says which of the two you 265 got rather than making you scan the text for a placeholder. 266 267 Idempotent per person and safe to re-run: the address survives — 268 it is what the file on your machine already says — and only the 269 key rotates, which stops the previous file working. 270 """ 271 resp = self._http.request( 272 "POST", 273 f"/competitions/{competition_id}/awd/vpn", 274 json={"public_key": public_key}, 275 ) 276 _raise_for_status(resp) 277 return AwdVpnPeer.model_validate(resp.json())
Put this machine on the arena, and get back a wg-quick file.
The classic arena is reachable over the tunnel and nowhere else — a gamebox publishes no ports and the match network is an internal bridge — so this is how anything that is not a platform-dispatched container joins it. The peer terminates on the node holding your own team's box.
Two key-custody modes. Pass public_key and this platform
never sees your private half; the file comes back with a
placeholder you fill in. Leave it empty and the platform mints a
pair and returns the private half once — config is then
complete, and private_key_included says which of the two you
got rather than making you scan the text for a placeholder.
Idempotent per person and safe to re-run: the address survives — it is what the file on your machine already says — and only the key rotates, which stops the previous file working.
279 def revoke_vpn(self, competition_id: str) -> bool: 280 """Take this machine off the arena. ``True`` if there was a peer. 281 282 ⚠️ **It lands on the next round, not on this call.** The peer 283 lives on a worker node's WireGuard device and the platform 284 reaches it only through the tick's authoritative re-push; what 285 this does is remove the row the next push is built from. 286 """ 287 resp = self._http.request("DELETE", f"/competitions/{competition_id}/awd/vpn") 288 _raise_for_status(resp) 289 return bool(resp.json().get("revoked", False))
Take this machine off the arena. True if there was a peer.
⚠️ It lands on the next round, not on this call. The peer lives on a worker node's WireGuard device and the platform reaches it only through the tick's authoritative re-push; what this does is remove the row the next push is built from.
291 def match(self, competition_id: str) -> AwdMatchStatus: 292 """The match's clock and rules — which round it is, and when it ends. 293 294 Public, like the board. ``tick_ends_at`` comes from the server 295 rather than being derived from ``starts_at`` + ``tick_seconds``: 296 the round arithmetic lives in exactly one place, and a client 297 that recomputed it would be the copy that disagrees. 298 299 ``current_tick`` is ``None`` before the first round and after the 300 last — not clamped, because "no round is in progress" is a real 301 state and a submission made then is judged against nothing. 302 """ 303 resp = self._http.request("GET", f"/competitions/{competition_id}/awd/match") 304 _raise_for_status(resp) 305 return AwdMatchStatus.model_validate(resp.json())
The match's clock and rules — which round it is, and when it ends.
Public, like the board. tick_ends_at comes from the server
rather than being derived from starts_at + tick_seconds:
the round arithmetic lives in exactly one place, and a client
that recomputed it would be the copy that disagrees.
current_tick is None before the first round and after the
last — not clamped, because "no round is in progress" is a real
state and a submission made then is judged against nothing.