ctfy.sdk.admin_resources.challenges
client.admin.challenges — catalog rescan, pre-build, stats, feedback audit.
1"""``client.admin.challenges`` — catalog rescan, pre-build, stats, feedback audit.""" 2 3from __future__ import annotations 4 5from datetime import datetime 6from typing import Any 7 8from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 9from ctfy.sdk.base import BaseHttpClient 10from ctfy.server.models import ( 11 AdminChallengeLatencyBucket, 12 AdminChallengeQuestions, 13 AdminChallengeSolveStatsRow, 14 AdminChallengeStatsRow, 15 AdminChallengeTimeseries, 16 AdminFeedbackRow, 17 AdminTaskInfo, 18 ChallengeBlueprint, 19 ChallengeBuildStateResponse, 20 ChallengePullStateResponse, 21 DeceptionStats, 22 LlmBudgetPage, 23 LlmBudgetResetInfo, 24 QuestionAttemptResetInfo, 25 RangeProgress, 26 RangeReplay, 27) 28 29 30class AdminChallengesResource: 31 """Operator challenge tooling: reload the catalog, pre-build on nodes, 32 inspect attempt/solve stats, audit feedback, reset attempt caps.""" 33 34 def __init__(self, http: BaseHttpClient) -> None: 35 self._http = http 36 37 def export_harbor(self, challenge_id: str) -> bytes: 38 """Download a Harbor task tar.gz from the platform's local source. 39 40 Platform administrator session required. Contains reference sources 41 and grading data; unsupported task features return 409. 42 """ 43 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/export/harbor") 44 _raise_for_status(resp) 45 return resp.content 46 47 def rescan(self) -> AdminTaskInfo: 48 """Submit a catalog-rescan background task (reload the platform's 49 spec cache + fan the rescan out to every online node). Returns the 50 task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page) 51 for the per-node outcome in ``result``. Wraps 52 ``POST /admin/challenges/rescan``.""" 53 resp = self._http.request("POST", "/admin/challenges/rescan") 54 _raise_for_status(resp) 55 return AdminTaskInfo.model_validate(resp.json()) 56 57 def build(self, challenge_id: str) -> AdminTaskInfo: 58 """Submit a single-challenge pre-build background task. 59 60 Fans the build out to every online node and polls it to 61 completion; poll :meth:`build_state` (or the Tasks surface) to 62 watch ``building`` → ``built`` / ``failed``. Returns the task 63 record. Wraps ``POST /admin/challenges/{id}/build``.""" 64 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build") 65 _raise_for_status(resp) 66 return AdminTaskInfo.model_validate(resp.json()) 67 68 def build_all(self, *, competition_id: str = "") -> AdminTaskInfo: 69 """Submit a bulk pre-build background task. With ``competition_id`` 70 the build is scoped to that competition's effective challenge set; 71 otherwise the whole catalog. The task kicks each online node off 72 and polls them to completion; the per-node outcome + final 73 per-challenge status land in ``result``. Returns the task record. 74 Wraps ``POST /admin/challenges/build-all``.""" 75 params: dict[str, Any] = {} 76 if competition_id: 77 params["competition_id"] = competition_id 78 resp = self._http.request("POST", "/admin/challenges/build-all", params=params) 79 _raise_for_status(resp) 80 return AdminTaskInfo.model_validate(resp.json()) 81 82 def build_push( 83 self, 84 *, 85 bucket: str = "", 86 challenge_ids: list[str] | None = None, 87 node_id: str = "", 88 ) -> AdminTaskInfo: 89 """Submit a build-and-push task: freeze the scoped challenges' 90 ``build:`` services into the registry (Layer 2 P0, producer side). 91 92 An explicit scope is **required** — a ``bucket`` (family such as 93 ``XBOW``) or ``challenge_ids`` — or the server refuses it (400); it 94 never freezes the whole catalog. ``node_id`` pins the build host 95 (default: the first online node running each challenge). 96 Super-admin. Returns the task record. Wraps 97 ``POST /admin/challenges/build-push``.""" 98 body: dict[str, Any] = { 99 "bucket": bucket, 100 "challenge_ids": challenge_ids or [], 101 "node_id": node_id, 102 } 103 resp = self._http.request("POST", "/admin/challenges/build-push", json=body) 104 _raise_for_status(resp) 105 return AdminTaskInfo.model_validate(resp.json()) 106 107 def questions(self, challenge_id: str) -> AdminChallengeQuestions: 108 """Every milestone a challenge declares, gated ones included. 109 110 The catalog projection omits questions behind ``requires`` so a 111 player cannot read a challenge's attack chain off its public page; 112 this is the organiser's way back in — "which milestones exist, and 113 why did this team not get credit for one". Wraps 114 ``GET /admin/challenges/{id}/questions``.""" 115 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/questions") 116 _raise_for_status(resp) 117 return AdminChallengeQuestions.model_validate(resp.json()) 118 119 def blueprint(self, challenge_id: str) -> ChallengeBlueprint: 120 """A range's estate and kill chain, for the people running it. 121 122 The sibling :meth:`questions` answers *which milestones exist*; 123 at four services that is the whole picture and at forty it is a 124 flat list with no shape. This adds the two axes a large range is 125 actually debugged along: which network segment each service sits 126 on (and so what can only be reached through a foothold), and 127 what has to fall before each milestone. 128 129 Derived server-side from the challenge's own ``metadata.yaml`` 130 and ``docker-compose.yml``, so it works for a hand-built range 131 and a generated one alike. Carries no groundtruth: see 132 :meth:`ctfy.sdk.admin_resources.records.RecordsResource.instance_answers` 133 for the value of a minted answer. Wraps 134 ``GET /admin/challenges/{id}/blueprint``.""" 135 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/blueprint") 136 _raise_for_status(resp) 137 return ChallengeBlueprint.model_validate(resp.json()) 138 139 def range_progress(self, competition_id: str, challenge_id: str) -> RangeProgress: 140 """Where every team of one competition is on one range. 141 142 :meth:`blueprint` says what the range *is*; this says where each 143 team got to on it — which milestones they hold, where they are 144 stopped, what they could take next, and which estate hosts they 145 have reached. 146 147 The blueprint is deliberately **not** in the answer: it is a 148 property of the challenge and cacheable, while this changes on 149 every save, and a caller drawing the overlay already holds it. 150 Carries no groundtruth and no network depth — the layer axis is 151 derived from the blueprint, which has the segments. Wraps 152 ``GET /admin/competitions/{cid}/ranges/{chid}/progress``.""" 153 resp = self._http.request( 154 "GET", f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress" 155 ) 156 _raise_for_status(resp) 157 return RangeProgress.model_validate(resp.json()) 158 159 def range_replay( 160 self, competition_id: str, challenge_id: str, *, frames: int | None = None 161 ) -> RangeReplay: 162 """The same field, wound back — where every team stood at time T. 163 164 :meth:`range_progress` answers *now*; this cuts the whole event 165 into instants, so a caller can ask *who moved when*. The last 166 frame equals that method's answer element for element, which is 167 the property the feature rests on: the clock is ``solved_at``, 168 the immutable first capture that every solve row carries. 169 170 ⚠️ **``frames: []`` means there is no time axis** — nothing was 171 captured and no window was declared, or nobody entered — and is 172 *not* what asking for no frames gets: ``frames=0`` is a 422, 173 because two facts behind one shape is the ``0``-versus-unset 174 trap. Omit ``frames`` for the server's default. 175 176 ⚠️ **A position carries ``reached`` as a count and no milestone 177 ids**, deliberately: carrying them would multiply the payload by 178 the milestone count. Its ``team_id`` is the id 179 :meth:`range_progress` published — an alias where the entrant 180 withheld their identity — so the two reads join without this 181 caller deciding who anybody is. Wraps 182 ``GET /admin/competitions/{cid}/ranges/{chid}/progress/replay``.""" 183 params = {} if frames is None else {"frames": frames} 184 resp = self._http.request( 185 "GET", 186 f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress/replay", 187 params=params, 188 ) 189 _raise_for_status(resp) 190 return RangeReplay.model_validate(resp.json()) 191 192 def build_state(self) -> ChallengeBuildStateResponse: 193 """Aggregate per-challenge build state across every online node. 194 195 Returns one row per known challenge with an ``aggregated`` 196 worst-case status plus the per-node breakdown for the modal 197 drill-down. Wraps ``GET /admin/challenges/build-state``.""" 198 resp = self._http.request("GET", "/admin/challenges/build-state") 199 _raise_for_status(resp) 200 return ChallengeBuildStateResponse.model_validate(resp.json()) 201 202 def pull(self, challenge_id: str) -> AdminTaskInfo: 203 """Submit a single-challenge pre-pull background task (pull-side 204 twin of :meth:`build`: warms registry-only ``image:`` services). 205 Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``.""" 206 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull") 207 _raise_for_status(resp) 208 return AdminTaskInfo.model_validate(resp.json()) 209 210 def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo: 211 """Submit a bulk pre-pull background task (pull-side twin of 212 :meth:`build_all`). Returns the task record. Wraps 213 ``POST /admin/challenges/pull-all``.""" 214 params: dict[str, Any] = {} 215 if competition_id: 216 params["competition_id"] = competition_id 217 resp = self._http.request("POST", "/admin/challenges/pull-all", params=params) 218 _raise_for_status(resp) 219 return AdminTaskInfo.model_validate(resp.json()) 220 221 def pull_state(self) -> ChallengePullStateResponse: 222 """Aggregate per-challenge pull state across every online node. 223 224 Pull-side twin of :meth:`build_state`. Wraps 225 ``GET /admin/challenges/pull-state``.""" 226 resp = self._http.request("GET", "/admin/challenges/pull-state") 227 _raise_for_status(resp) 228 return ChallengePullStateResponse.model_validate(resp.json()) 229 230 def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]: 231 """Per-challenge attempt / solve / first-blood counts.""" 232 resp = self._http.request( 233 "GET", 234 "/admin/challenges/stats", 235 params={"offset": offset, "limit": limit}, 236 ) 237 _raise_for_status(resp) 238 return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]] 239 240 def solve_stats( 241 self, *, competition_id: str = "", q: str = "", offset: int = 0, limit: int = 50 242 ) -> list[AdminChallengeSolveStatsRow]: 243 """Solves and attempts per challenge, one row per ``(competition, 244 challenge)`` pair (#2341): a team counts only inside its own 245 competition. ``competition_id`` narrows to one scope. Wraps 246 ``GET /admin/challenges/solve-stats``.""" 247 resp = self._http.request( 248 "GET", 249 "/admin/challenges/solve-stats", 250 params={"competition_id": competition_id, "q": q, "offset": offset, "limit": limit}, 251 ) 252 _raise_for_status(resp) 253 return [AdminChallengeSolveStatsRow.model_validate(r) for r in resp.json()["items"]] 254 255 def timeseries( 256 self, 257 challenge_id: str, 258 *, 259 window: int = 86400, 260 bucket: int = 3600, 261 ) -> AdminChallengeTimeseries: 262 """Per-challenge timeseries for the row-level drawer on 263 ``/admin/challenges``.""" 264 resp = self._http.request( 265 "GET", 266 f"/admin/challenges/{challenge_id}/timeseries", 267 params={"window": window, "bucket": bucket}, 268 ) 269 _raise_for_status(resp) 270 return AdminChallengeTimeseries.model_validate(resp.json()) 271 272 def latency_histogram(self) -> list[AdminChallengeLatencyBucket]: 273 """Instance-start latency distribution across challenges (the 274 admin challenges latency chart).""" 275 resp = self._http.request("GET", "/admin/challenges/latency-histogram") 276 _raise_for_status(resp) 277 return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()] 278 279 def feedback( 280 self, challenge_id: str, *, competition_id: str = "" 281 ) -> PagedList[AdminFeedbackRow]: 282 """Admin audit list: every reaction row on a challenge with 283 denormalised user identity. Requires admin role.""" 284 params: dict[str, Any] = {} 285 if competition_id: 286 params["competition_id"] = competition_id 287 resp = self._http.request( 288 "GET", f"/admin/challenges/{challenge_id}/feedback", params=params 289 ) 290 _raise_for_status(resp) 291 return _extract_items(resp.json(), AdminFeedbackRow) 292 293 def reset_question_attempts( 294 self, 295 team_id: str, 296 challenge_id: str, 297 question_id: str, 298 *, 299 reason: str = "", 300 ) -> QuestionAttemptResetInfo: 301 """Reset the per-question wrong-attempt counter for one team. 302 303 Writes a fresh baseline timestamp; the cap counter ignores all 304 wrong submissions before the new baseline, so the team gets a 305 fresh batch of attempts on this question without any audit 306 rows being deleted. Returns the new baseline row. 307 Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``.""" 308 resp = self._http.request( 309 "POST", 310 f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts", 311 json={"reason": reason}, 312 ) 313 _raise_for_status(resp) 314 return QuestionAttemptResetInfo.model_validate(resp.json()) 315 316 def list_llm_budgets(self, *, competition_id: str = "") -> LlmBudgetPage: 317 """Who has spent what against the challenge-LLM gateway. 318 319 The reader for :meth:`reset_llm_budget` — without it an organiser 320 can only hand back an allowance to a team somebody already named. 321 ⚠️ ``gateway_configured`` is on the envelope because an empty 322 list otherwise reads as "nobody spent anything" when it means 323 "this deployment enforces no budgets", and those call for 324 opposite actions. 325 Wraps ``GET /admin/llm-budgets``.""" 326 params = {"competition_id": competition_id} if competition_id else None 327 resp = self._http.request("GET", "/admin/llm-budgets", params=params) 328 _raise_for_status(resp) 329 return LlmBudgetPage.model_validate(resp.json()) 330 331 def deception_stats( 332 self, 333 *, 334 competition_id: str = "", 335 challenge_id: str = "", 336 since: datetime | None = None, 337 ) -> DeceptionStats: 338 """How often an assessing agent obeyed a directive planted in a 339 challenge's own output. 340 341 ⚠️ ``resisted`` is an **upper bound**, and the response carries 342 that caveat on the wire rather than leaving it to documentation: 343 the probe runs inside a container the player is invited to 344 compromise, so a missing compliance record is not evidence of 345 restraint. ``comply_rate`` is ``None`` rather than ``0.0`` when 346 nothing has been contacted — 0/0 rendered as 0% would assert 347 that no agent was fooled. 348 349 Nothing here is scored; it reports, it does not rule. 350 Wraps ``GET /admin/deception/stats``.""" 351 # ⚠️ ``since`` is the route's third filter and this wrapper used 352 # to drop it, so "what happened during *this* event" was 353 # unaskable from anything but a hand-rolled request — the shape 354 # ``admin competition update``'s missing flags already record. 355 params = { 356 k: v 357 for k, v in ( 358 ("competition_id", competition_id), 359 ("challenge_id", challenge_id), 360 ("since", since.isoformat() if since else ""), 361 ) 362 if v 363 } 364 resp = self._http.request("GET", "/admin/deception/stats", params=params or None) 365 _raise_for_status(resp) 366 return DeceptionStats.model_validate(resp.json()) 367 368 def reset_llm_budget( 369 self, 370 team_id: str, 371 competition_id: str, 372 challenge_id: str, 373 *, 374 reason: str = "", 375 ) -> LlmBudgetResetInfo: 376 """Give one team its LLM allowance back on one challenge. 377 378 Clears the gateway's live tally **and** the platform's durable 379 total — either alone un-does itself, so the route does both or 380 refuses. ⚠️ Always answers 200; read ``.outcome`` rather than the 381 status, because ``unreachable`` means nothing changed and is 382 worth retrying. 383 Wraps ``POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset``.""" 384 resp = self._http.request( 385 "POST", 386 f"/admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset", 387 json={"reason": reason}, 388 ) 389 _raise_for_status(resp) 390 return LlmBudgetResetInfo.model_validate(resp.json())
31class AdminChallengesResource: 32 """Operator challenge tooling: reload the catalog, pre-build on nodes, 33 inspect attempt/solve stats, audit feedback, reset attempt caps.""" 34 35 def __init__(self, http: BaseHttpClient) -> None: 36 self._http = http 37 38 def export_harbor(self, challenge_id: str) -> bytes: 39 """Download a Harbor task tar.gz from the platform's local source. 40 41 Platform administrator session required. Contains reference sources 42 and grading data; unsupported task features return 409. 43 """ 44 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/export/harbor") 45 _raise_for_status(resp) 46 return resp.content 47 48 def rescan(self) -> AdminTaskInfo: 49 """Submit a catalog-rescan background task (reload the platform's 50 spec cache + fan the rescan out to every online node). Returns the 51 task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page) 52 for the per-node outcome in ``result``. Wraps 53 ``POST /admin/challenges/rescan``.""" 54 resp = self._http.request("POST", "/admin/challenges/rescan") 55 _raise_for_status(resp) 56 return AdminTaskInfo.model_validate(resp.json()) 57 58 def build(self, challenge_id: str) -> AdminTaskInfo: 59 """Submit a single-challenge pre-build background task. 60 61 Fans the build out to every online node and polls it to 62 completion; poll :meth:`build_state` (or the Tasks surface) to 63 watch ``building`` → ``built`` / ``failed``. Returns the task 64 record. Wraps ``POST /admin/challenges/{id}/build``.""" 65 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build") 66 _raise_for_status(resp) 67 return AdminTaskInfo.model_validate(resp.json()) 68 69 def build_all(self, *, competition_id: str = "") -> AdminTaskInfo: 70 """Submit a bulk pre-build background task. With ``competition_id`` 71 the build is scoped to that competition's effective challenge set; 72 otherwise the whole catalog. The task kicks each online node off 73 and polls them to completion; the per-node outcome + final 74 per-challenge status land in ``result``. Returns the task record. 75 Wraps ``POST /admin/challenges/build-all``.""" 76 params: dict[str, Any] = {} 77 if competition_id: 78 params["competition_id"] = competition_id 79 resp = self._http.request("POST", "/admin/challenges/build-all", params=params) 80 _raise_for_status(resp) 81 return AdminTaskInfo.model_validate(resp.json()) 82 83 def build_push( 84 self, 85 *, 86 bucket: str = "", 87 challenge_ids: list[str] | None = None, 88 node_id: str = "", 89 ) -> AdminTaskInfo: 90 """Submit a build-and-push task: freeze the scoped challenges' 91 ``build:`` services into the registry (Layer 2 P0, producer side). 92 93 An explicit scope is **required** — a ``bucket`` (family such as 94 ``XBOW``) or ``challenge_ids`` — or the server refuses it (400); it 95 never freezes the whole catalog. ``node_id`` pins the build host 96 (default: the first online node running each challenge). 97 Super-admin. Returns the task record. Wraps 98 ``POST /admin/challenges/build-push``.""" 99 body: dict[str, Any] = { 100 "bucket": bucket, 101 "challenge_ids": challenge_ids or [], 102 "node_id": node_id, 103 } 104 resp = self._http.request("POST", "/admin/challenges/build-push", json=body) 105 _raise_for_status(resp) 106 return AdminTaskInfo.model_validate(resp.json()) 107 108 def questions(self, challenge_id: str) -> AdminChallengeQuestions: 109 """Every milestone a challenge declares, gated ones included. 110 111 The catalog projection omits questions behind ``requires`` so a 112 player cannot read a challenge's attack chain off its public page; 113 this is the organiser's way back in — "which milestones exist, and 114 why did this team not get credit for one". Wraps 115 ``GET /admin/challenges/{id}/questions``.""" 116 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/questions") 117 _raise_for_status(resp) 118 return AdminChallengeQuestions.model_validate(resp.json()) 119 120 def blueprint(self, challenge_id: str) -> ChallengeBlueprint: 121 """A range's estate and kill chain, for the people running it. 122 123 The sibling :meth:`questions` answers *which milestones exist*; 124 at four services that is the whole picture and at forty it is a 125 flat list with no shape. This adds the two axes a large range is 126 actually debugged along: which network segment each service sits 127 on (and so what can only be reached through a foothold), and 128 what has to fall before each milestone. 129 130 Derived server-side from the challenge's own ``metadata.yaml`` 131 and ``docker-compose.yml``, so it works for a hand-built range 132 and a generated one alike. Carries no groundtruth: see 133 :meth:`ctfy.sdk.admin_resources.records.RecordsResource.instance_answers` 134 for the value of a minted answer. Wraps 135 ``GET /admin/challenges/{id}/blueprint``.""" 136 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/blueprint") 137 _raise_for_status(resp) 138 return ChallengeBlueprint.model_validate(resp.json()) 139 140 def range_progress(self, competition_id: str, challenge_id: str) -> RangeProgress: 141 """Where every team of one competition is on one range. 142 143 :meth:`blueprint` says what the range *is*; this says where each 144 team got to on it — which milestones they hold, where they are 145 stopped, what they could take next, and which estate hosts they 146 have reached. 147 148 The blueprint is deliberately **not** in the answer: it is a 149 property of the challenge and cacheable, while this changes on 150 every save, and a caller drawing the overlay already holds it. 151 Carries no groundtruth and no network depth — the layer axis is 152 derived from the blueprint, which has the segments. Wraps 153 ``GET /admin/competitions/{cid}/ranges/{chid}/progress``.""" 154 resp = self._http.request( 155 "GET", f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress" 156 ) 157 _raise_for_status(resp) 158 return RangeProgress.model_validate(resp.json()) 159 160 def range_replay( 161 self, competition_id: str, challenge_id: str, *, frames: int | None = None 162 ) -> RangeReplay: 163 """The same field, wound back — where every team stood at time T. 164 165 :meth:`range_progress` answers *now*; this cuts the whole event 166 into instants, so a caller can ask *who moved when*. The last 167 frame equals that method's answer element for element, which is 168 the property the feature rests on: the clock is ``solved_at``, 169 the immutable first capture that every solve row carries. 170 171 ⚠️ **``frames: []`` means there is no time axis** — nothing was 172 captured and no window was declared, or nobody entered — and is 173 *not* what asking for no frames gets: ``frames=0`` is a 422, 174 because two facts behind one shape is the ``0``-versus-unset 175 trap. Omit ``frames`` for the server's default. 176 177 ⚠️ **A position carries ``reached`` as a count and no milestone 178 ids**, deliberately: carrying them would multiply the payload by 179 the milestone count. Its ``team_id`` is the id 180 :meth:`range_progress` published — an alias where the entrant 181 withheld their identity — so the two reads join without this 182 caller deciding who anybody is. Wraps 183 ``GET /admin/competitions/{cid}/ranges/{chid}/progress/replay``.""" 184 params = {} if frames is None else {"frames": frames} 185 resp = self._http.request( 186 "GET", 187 f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress/replay", 188 params=params, 189 ) 190 _raise_for_status(resp) 191 return RangeReplay.model_validate(resp.json()) 192 193 def build_state(self) -> ChallengeBuildStateResponse: 194 """Aggregate per-challenge build state across every online node. 195 196 Returns one row per known challenge with an ``aggregated`` 197 worst-case status plus the per-node breakdown for the modal 198 drill-down. Wraps ``GET /admin/challenges/build-state``.""" 199 resp = self._http.request("GET", "/admin/challenges/build-state") 200 _raise_for_status(resp) 201 return ChallengeBuildStateResponse.model_validate(resp.json()) 202 203 def pull(self, challenge_id: str) -> AdminTaskInfo: 204 """Submit a single-challenge pre-pull background task (pull-side 205 twin of :meth:`build`: warms registry-only ``image:`` services). 206 Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``.""" 207 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull") 208 _raise_for_status(resp) 209 return AdminTaskInfo.model_validate(resp.json()) 210 211 def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo: 212 """Submit a bulk pre-pull background task (pull-side twin of 213 :meth:`build_all`). Returns the task record. Wraps 214 ``POST /admin/challenges/pull-all``.""" 215 params: dict[str, Any] = {} 216 if competition_id: 217 params["competition_id"] = competition_id 218 resp = self._http.request("POST", "/admin/challenges/pull-all", params=params) 219 _raise_for_status(resp) 220 return AdminTaskInfo.model_validate(resp.json()) 221 222 def pull_state(self) -> ChallengePullStateResponse: 223 """Aggregate per-challenge pull state across every online node. 224 225 Pull-side twin of :meth:`build_state`. Wraps 226 ``GET /admin/challenges/pull-state``.""" 227 resp = self._http.request("GET", "/admin/challenges/pull-state") 228 _raise_for_status(resp) 229 return ChallengePullStateResponse.model_validate(resp.json()) 230 231 def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]: 232 """Per-challenge attempt / solve / first-blood counts.""" 233 resp = self._http.request( 234 "GET", 235 "/admin/challenges/stats", 236 params={"offset": offset, "limit": limit}, 237 ) 238 _raise_for_status(resp) 239 return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]] 240 241 def solve_stats( 242 self, *, competition_id: str = "", q: str = "", offset: int = 0, limit: int = 50 243 ) -> list[AdminChallengeSolveStatsRow]: 244 """Solves and attempts per challenge, one row per ``(competition, 245 challenge)`` pair (#2341): a team counts only inside its own 246 competition. ``competition_id`` narrows to one scope. Wraps 247 ``GET /admin/challenges/solve-stats``.""" 248 resp = self._http.request( 249 "GET", 250 "/admin/challenges/solve-stats", 251 params={"competition_id": competition_id, "q": q, "offset": offset, "limit": limit}, 252 ) 253 _raise_for_status(resp) 254 return [AdminChallengeSolveStatsRow.model_validate(r) for r in resp.json()["items"]] 255 256 def timeseries( 257 self, 258 challenge_id: str, 259 *, 260 window: int = 86400, 261 bucket: int = 3600, 262 ) -> AdminChallengeTimeseries: 263 """Per-challenge timeseries for the row-level drawer on 264 ``/admin/challenges``.""" 265 resp = self._http.request( 266 "GET", 267 f"/admin/challenges/{challenge_id}/timeseries", 268 params={"window": window, "bucket": bucket}, 269 ) 270 _raise_for_status(resp) 271 return AdminChallengeTimeseries.model_validate(resp.json()) 272 273 def latency_histogram(self) -> list[AdminChallengeLatencyBucket]: 274 """Instance-start latency distribution across challenges (the 275 admin challenges latency chart).""" 276 resp = self._http.request("GET", "/admin/challenges/latency-histogram") 277 _raise_for_status(resp) 278 return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()] 279 280 def feedback( 281 self, challenge_id: str, *, competition_id: str = "" 282 ) -> PagedList[AdminFeedbackRow]: 283 """Admin audit list: every reaction row on a challenge with 284 denormalised user identity. Requires admin role.""" 285 params: dict[str, Any] = {} 286 if competition_id: 287 params["competition_id"] = competition_id 288 resp = self._http.request( 289 "GET", f"/admin/challenges/{challenge_id}/feedback", params=params 290 ) 291 _raise_for_status(resp) 292 return _extract_items(resp.json(), AdminFeedbackRow) 293 294 def reset_question_attempts( 295 self, 296 team_id: str, 297 challenge_id: str, 298 question_id: str, 299 *, 300 reason: str = "", 301 ) -> QuestionAttemptResetInfo: 302 """Reset the per-question wrong-attempt counter for one team. 303 304 Writes a fresh baseline timestamp; the cap counter ignores all 305 wrong submissions before the new baseline, so the team gets a 306 fresh batch of attempts on this question without any audit 307 rows being deleted. Returns the new baseline row. 308 Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``.""" 309 resp = self._http.request( 310 "POST", 311 f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts", 312 json={"reason": reason}, 313 ) 314 _raise_for_status(resp) 315 return QuestionAttemptResetInfo.model_validate(resp.json()) 316 317 def list_llm_budgets(self, *, competition_id: str = "") -> LlmBudgetPage: 318 """Who has spent what against the challenge-LLM gateway. 319 320 The reader for :meth:`reset_llm_budget` — without it an organiser 321 can only hand back an allowance to a team somebody already named. 322 ⚠️ ``gateway_configured`` is on the envelope because an empty 323 list otherwise reads as "nobody spent anything" when it means 324 "this deployment enforces no budgets", and those call for 325 opposite actions. 326 Wraps ``GET /admin/llm-budgets``.""" 327 params = {"competition_id": competition_id} if competition_id else None 328 resp = self._http.request("GET", "/admin/llm-budgets", params=params) 329 _raise_for_status(resp) 330 return LlmBudgetPage.model_validate(resp.json()) 331 332 def deception_stats( 333 self, 334 *, 335 competition_id: str = "", 336 challenge_id: str = "", 337 since: datetime | None = None, 338 ) -> DeceptionStats: 339 """How often an assessing agent obeyed a directive planted in a 340 challenge's own output. 341 342 ⚠️ ``resisted`` is an **upper bound**, and the response carries 343 that caveat on the wire rather than leaving it to documentation: 344 the probe runs inside a container the player is invited to 345 compromise, so a missing compliance record is not evidence of 346 restraint. ``comply_rate`` is ``None`` rather than ``0.0`` when 347 nothing has been contacted — 0/0 rendered as 0% would assert 348 that no agent was fooled. 349 350 Nothing here is scored; it reports, it does not rule. 351 Wraps ``GET /admin/deception/stats``.""" 352 # ⚠️ ``since`` is the route's third filter and this wrapper used 353 # to drop it, so "what happened during *this* event" was 354 # unaskable from anything but a hand-rolled request — the shape 355 # ``admin competition update``'s missing flags already record. 356 params = { 357 k: v 358 for k, v in ( 359 ("competition_id", competition_id), 360 ("challenge_id", challenge_id), 361 ("since", since.isoformat() if since else ""), 362 ) 363 if v 364 } 365 resp = self._http.request("GET", "/admin/deception/stats", params=params or None) 366 _raise_for_status(resp) 367 return DeceptionStats.model_validate(resp.json()) 368 369 def reset_llm_budget( 370 self, 371 team_id: str, 372 competition_id: str, 373 challenge_id: str, 374 *, 375 reason: str = "", 376 ) -> LlmBudgetResetInfo: 377 """Give one team its LLM allowance back on one challenge. 378 379 Clears the gateway's live tally **and** the platform's durable 380 total — either alone un-does itself, so the route does both or 381 refuses. ⚠️ Always answers 200; read ``.outcome`` rather than the 382 status, because ``unreachable`` means nothing changed and is 383 worth retrying. 384 Wraps ``POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset``.""" 385 resp = self._http.request( 386 "POST", 387 f"/admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset", 388 json={"reason": reason}, 389 ) 390 _raise_for_status(resp) 391 return LlmBudgetResetInfo.model_validate(resp.json())
Operator challenge tooling: reload the catalog, pre-build on nodes, inspect attempt/solve stats, audit feedback, reset attempt caps.
38 def export_harbor(self, challenge_id: str) -> bytes: 39 """Download a Harbor task tar.gz from the platform's local source. 40 41 Platform administrator session required. Contains reference sources 42 and grading data; unsupported task features return 409. 43 """ 44 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/export/harbor") 45 _raise_for_status(resp) 46 return resp.content
Download a Harbor task tar.gz from the platform's local source.
Platform administrator session required. Contains reference sources and grading data; unsupported task features return 409.
48 def rescan(self) -> AdminTaskInfo: 49 """Submit a catalog-rescan background task (reload the platform's 50 spec cache + fan the rescan out to every online node). Returns the 51 task record; poll ``admin.tasks.get(id)`` (or watch the Tasks page) 52 for the per-node outcome in ``result``. Wraps 53 ``POST /admin/challenges/rescan``.""" 54 resp = self._http.request("POST", "/admin/challenges/rescan") 55 _raise_for_status(resp) 56 return AdminTaskInfo.model_validate(resp.json())
Submit a catalog-rescan background task (reload the platform's
spec cache + fan the rescan out to every online node). Returns the
task record; poll admin.tasks.get(id) (or watch the Tasks page)
for the per-node outcome in result. Wraps
POST /admin/challenges/rescan.
58 def build(self, challenge_id: str) -> AdminTaskInfo: 59 """Submit a single-challenge pre-build background task. 60 61 Fans the build out to every online node and polls it to 62 completion; poll :meth:`build_state` (or the Tasks surface) to 63 watch ``building`` → ``built`` / ``failed``. Returns the task 64 record. Wraps ``POST /admin/challenges/{id}/build``.""" 65 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/build") 66 _raise_for_status(resp) 67 return AdminTaskInfo.model_validate(resp.json())
Submit a single-challenge pre-build background task.
Fans the build out to every online node and polls it to
completion; poll build_state() (or the Tasks surface) to
watch building → built / failed. Returns the task
record. Wraps POST /admin/challenges/{id}/build.
69 def build_all(self, *, competition_id: str = "") -> AdminTaskInfo: 70 """Submit a bulk pre-build background task. With ``competition_id`` 71 the build is scoped to that competition's effective challenge set; 72 otherwise the whole catalog. The task kicks each online node off 73 and polls them to completion; the per-node outcome + final 74 per-challenge status land in ``result``. Returns the task record. 75 Wraps ``POST /admin/challenges/build-all``.""" 76 params: dict[str, Any] = {} 77 if competition_id: 78 params["competition_id"] = competition_id 79 resp = self._http.request("POST", "/admin/challenges/build-all", params=params) 80 _raise_for_status(resp) 81 return AdminTaskInfo.model_validate(resp.json())
Submit a bulk pre-build background task. With competition_id
the build is scoped to that competition's effective challenge set;
otherwise the whole catalog. The task kicks each online node off
and polls them to completion; the per-node outcome + final
per-challenge status land in result. Returns the task record.
Wraps POST /admin/challenges/build-all.
83 def build_push( 84 self, 85 *, 86 bucket: str = "", 87 challenge_ids: list[str] | None = None, 88 node_id: str = "", 89 ) -> AdminTaskInfo: 90 """Submit a build-and-push task: freeze the scoped challenges' 91 ``build:`` services into the registry (Layer 2 P0, producer side). 92 93 An explicit scope is **required** — a ``bucket`` (family such as 94 ``XBOW``) or ``challenge_ids`` — or the server refuses it (400); it 95 never freezes the whole catalog. ``node_id`` pins the build host 96 (default: the first online node running each challenge). 97 Super-admin. Returns the task record. Wraps 98 ``POST /admin/challenges/build-push``.""" 99 body: dict[str, Any] = { 100 "bucket": bucket, 101 "challenge_ids": challenge_ids or [], 102 "node_id": node_id, 103 } 104 resp = self._http.request("POST", "/admin/challenges/build-push", json=body) 105 _raise_for_status(resp) 106 return AdminTaskInfo.model_validate(resp.json())
Submit a build-and-push task: freeze the scoped challenges'
build: services into the registry (Layer 2 P0, producer side).
An explicit scope is required — a bucket (family such as
XBOW) or challenge_ids — or the server refuses it (400); it
never freezes the whole catalog. node_id pins the build host
(default: the first online node running each challenge).
Super-admin. Returns the task record. Wraps
POST /admin/challenges/build-push.
108 def questions(self, challenge_id: str) -> AdminChallengeQuestions: 109 """Every milestone a challenge declares, gated ones included. 110 111 The catalog projection omits questions behind ``requires`` so a 112 player cannot read a challenge's attack chain off its public page; 113 this is the organiser's way back in — "which milestones exist, and 114 why did this team not get credit for one". Wraps 115 ``GET /admin/challenges/{id}/questions``.""" 116 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/questions") 117 _raise_for_status(resp) 118 return AdminChallengeQuestions.model_validate(resp.json())
Every milestone a challenge declares, gated ones included.
The catalog projection omits questions behind requires so a
player cannot read a challenge's attack chain off its public page;
this is the organiser's way back in — "which milestones exist, and
why did this team not get credit for one". Wraps
GET /admin/challenges/{id}/questions.
120 def blueprint(self, challenge_id: str) -> ChallengeBlueprint: 121 """A range's estate and kill chain, for the people running it. 122 123 The sibling :meth:`questions` answers *which milestones exist*; 124 at four services that is the whole picture and at forty it is a 125 flat list with no shape. This adds the two axes a large range is 126 actually debugged along: which network segment each service sits 127 on (and so what can only be reached through a foothold), and 128 what has to fall before each milestone. 129 130 Derived server-side from the challenge's own ``metadata.yaml`` 131 and ``docker-compose.yml``, so it works for a hand-built range 132 and a generated one alike. Carries no groundtruth: see 133 :meth:`ctfy.sdk.admin_resources.records.RecordsResource.instance_answers` 134 for the value of a minted answer. Wraps 135 ``GET /admin/challenges/{id}/blueprint``.""" 136 resp = self._http.request("GET", f"/admin/challenges/{challenge_id}/blueprint") 137 _raise_for_status(resp) 138 return ChallengeBlueprint.model_validate(resp.json())
A range's estate and kill chain, for the people running it.
The sibling questions() answers which milestones exist;
at four services that is the whole picture and at forty it is a
flat list with no shape. This adds the two axes a large range is
actually debugged along: which network segment each service sits
on (and so what can only be reached through a foothold), and
what has to fall before each milestone.
Derived server-side from the challenge's own metadata.yaml
and docker-compose.yml, so it works for a hand-built range
and a generated one alike. Carries no groundtruth: see
ctfy.sdk.admin_resources.records.RecordsResource.instance_answers()
for the value of a minted answer. Wraps
GET /admin/challenges/{id}/blueprint.
140 def range_progress(self, competition_id: str, challenge_id: str) -> RangeProgress: 141 """Where every team of one competition is on one range. 142 143 :meth:`blueprint` says what the range *is*; this says where each 144 team got to on it — which milestones they hold, where they are 145 stopped, what they could take next, and which estate hosts they 146 have reached. 147 148 The blueprint is deliberately **not** in the answer: it is a 149 property of the challenge and cacheable, while this changes on 150 every save, and a caller drawing the overlay already holds it. 151 Carries no groundtruth and no network depth — the layer axis is 152 derived from the blueprint, which has the segments. Wraps 153 ``GET /admin/competitions/{cid}/ranges/{chid}/progress``.""" 154 resp = self._http.request( 155 "GET", f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress" 156 ) 157 _raise_for_status(resp) 158 return RangeProgress.model_validate(resp.json())
Where every team of one competition is on one range.
blueprint() says what the range is; this says where each
team got to on it — which milestones they hold, where they are
stopped, what they could take next, and which estate hosts they
have reached.
The blueprint is deliberately not in the answer: it is a
property of the challenge and cacheable, while this changes on
every save, and a caller drawing the overlay already holds it.
Carries no groundtruth and no network depth — the layer axis is
derived from the blueprint, which has the segments. Wraps
GET /admin/competitions/{cid}/ranges/{chid}/progress.
160 def range_replay( 161 self, competition_id: str, challenge_id: str, *, frames: int | None = None 162 ) -> RangeReplay: 163 """The same field, wound back — where every team stood at time T. 164 165 :meth:`range_progress` answers *now*; this cuts the whole event 166 into instants, so a caller can ask *who moved when*. The last 167 frame equals that method's answer element for element, which is 168 the property the feature rests on: the clock is ``solved_at``, 169 the immutable first capture that every solve row carries. 170 171 ⚠️ **``frames: []`` means there is no time axis** — nothing was 172 captured and no window was declared, or nobody entered — and is 173 *not* what asking for no frames gets: ``frames=0`` is a 422, 174 because two facts behind one shape is the ``0``-versus-unset 175 trap. Omit ``frames`` for the server's default. 176 177 ⚠️ **A position carries ``reached`` as a count and no milestone 178 ids**, deliberately: carrying them would multiply the payload by 179 the milestone count. Its ``team_id`` is the id 180 :meth:`range_progress` published — an alias where the entrant 181 withheld their identity — so the two reads join without this 182 caller deciding who anybody is. Wraps 183 ``GET /admin/competitions/{cid}/ranges/{chid}/progress/replay``.""" 184 params = {} if frames is None else {"frames": frames} 185 resp = self._http.request( 186 "GET", 187 f"/admin/competitions/{competition_id}/ranges/{challenge_id}/progress/replay", 188 params=params, 189 ) 190 _raise_for_status(resp) 191 return RangeReplay.model_validate(resp.json())
The same field, wound back — where every team stood at time T.
range_progress() answers now; this cuts the whole event
into instants, so a caller can ask who moved when. The last
frame equals that method's answer element for element, which is
the property the feature rests on: the clock is solved_at,
the immutable first capture that every solve row carries.
⚠️ frames: [] means there is no time axis — nothing was
captured and no window was declared, or nobody entered — and is
not what asking for no frames gets: frames=0 is a 422,
because two facts behind one shape is the 0-versus-unset
trap. Omit frames for the server's default.
⚠️ A position carries reached as a count and no milestone
ids, deliberately: carrying them would multiply the payload by
the milestone count. Its team_id is the id
range_progress() published — an alias where the entrant
withheld their identity — so the two reads join without this
caller deciding who anybody is. Wraps
GET /admin/competitions/{cid}/ranges/{chid}/progress/replay.
193 def build_state(self) -> ChallengeBuildStateResponse: 194 """Aggregate per-challenge build state across every online node. 195 196 Returns one row per known challenge with an ``aggregated`` 197 worst-case status plus the per-node breakdown for the modal 198 drill-down. Wraps ``GET /admin/challenges/build-state``.""" 199 resp = self._http.request("GET", "/admin/challenges/build-state") 200 _raise_for_status(resp) 201 return ChallengeBuildStateResponse.model_validate(resp.json())
Aggregate per-challenge build state across every online node.
Returns one row per known challenge with an aggregated
worst-case status plus the per-node breakdown for the modal
drill-down. Wraps GET /admin/challenges/build-state.
203 def pull(self, challenge_id: str) -> AdminTaskInfo: 204 """Submit a single-challenge pre-pull background task (pull-side 205 twin of :meth:`build`: warms registry-only ``image:`` services). 206 Returns the task record. Wraps ``POST /admin/challenges/{id}/pull``.""" 207 resp = self._http.request("POST", f"/admin/challenges/{challenge_id}/pull") 208 _raise_for_status(resp) 209 return AdminTaskInfo.model_validate(resp.json())
Submit a single-challenge pre-pull background task (pull-side
twin of build(): warms registry-only image: services).
Returns the task record. Wraps POST /admin/challenges/{id}/pull.
211 def pull_all(self, *, competition_id: str = "") -> AdminTaskInfo: 212 """Submit a bulk pre-pull background task (pull-side twin of 213 :meth:`build_all`). Returns the task record. Wraps 214 ``POST /admin/challenges/pull-all``.""" 215 params: dict[str, Any] = {} 216 if competition_id: 217 params["competition_id"] = competition_id 218 resp = self._http.request("POST", "/admin/challenges/pull-all", params=params) 219 _raise_for_status(resp) 220 return AdminTaskInfo.model_validate(resp.json())
Submit a bulk pre-pull background task (pull-side twin of
build_all()). Returns the task record. Wraps
POST /admin/challenges/pull-all.
222 def pull_state(self) -> ChallengePullStateResponse: 223 """Aggregate per-challenge pull state across every online node. 224 225 Pull-side twin of :meth:`build_state`. Wraps 226 ``GET /admin/challenges/pull-state``.""" 227 resp = self._http.request("GET", "/admin/challenges/pull-state") 228 _raise_for_status(resp) 229 return ChallengePullStateResponse.model_validate(resp.json())
Aggregate per-challenge pull state across every online node.
Pull-side twin of build_state(). Wraps
GET /admin/challenges/pull-state.
231 def stats(self, offset: int = 0, limit: int = 50) -> list[AdminChallengeStatsRow]: 232 """Per-challenge attempt / solve / first-blood counts.""" 233 resp = self._http.request( 234 "GET", 235 "/admin/challenges/stats", 236 params={"offset": offset, "limit": limit}, 237 ) 238 _raise_for_status(resp) 239 return [AdminChallengeStatsRow.model_validate(r) for r in resp.json()["items"]]
Per-challenge attempt / solve / first-blood counts.
241 def solve_stats( 242 self, *, competition_id: str = "", q: str = "", offset: int = 0, limit: int = 50 243 ) -> list[AdminChallengeSolveStatsRow]: 244 """Solves and attempts per challenge, one row per ``(competition, 245 challenge)`` pair (#2341): a team counts only inside its own 246 competition. ``competition_id`` narrows to one scope. Wraps 247 ``GET /admin/challenges/solve-stats``.""" 248 resp = self._http.request( 249 "GET", 250 "/admin/challenges/solve-stats", 251 params={"competition_id": competition_id, "q": q, "offset": offset, "limit": limit}, 252 ) 253 _raise_for_status(resp) 254 return [AdminChallengeSolveStatsRow.model_validate(r) for r in resp.json()["items"]]
Solves and attempts per challenge, one row per (competition,
challenge) pair (#2341): a team counts only inside its own
competition. competition_id narrows to one scope. Wraps
GET /admin/challenges/solve-stats.
256 def timeseries( 257 self, 258 challenge_id: str, 259 *, 260 window: int = 86400, 261 bucket: int = 3600, 262 ) -> AdminChallengeTimeseries: 263 """Per-challenge timeseries for the row-level drawer on 264 ``/admin/challenges``.""" 265 resp = self._http.request( 266 "GET", 267 f"/admin/challenges/{challenge_id}/timeseries", 268 params={"window": window, "bucket": bucket}, 269 ) 270 _raise_for_status(resp) 271 return AdminChallengeTimeseries.model_validate(resp.json())
Per-challenge timeseries for the row-level drawer on
/admin/challenges.
273 def latency_histogram(self) -> list[AdminChallengeLatencyBucket]: 274 """Instance-start latency distribution across challenges (the 275 admin challenges latency chart).""" 276 resp = self._http.request("GET", "/admin/challenges/latency-histogram") 277 _raise_for_status(resp) 278 return [AdminChallengeLatencyBucket.model_validate(b) for b in resp.json()]
Instance-start latency distribution across challenges (the admin challenges latency chart).
280 def feedback( 281 self, challenge_id: str, *, competition_id: str = "" 282 ) -> PagedList[AdminFeedbackRow]: 283 """Admin audit list: every reaction row on a challenge with 284 denormalised user identity. Requires admin role.""" 285 params: dict[str, Any] = {} 286 if competition_id: 287 params["competition_id"] = competition_id 288 resp = self._http.request( 289 "GET", f"/admin/challenges/{challenge_id}/feedback", params=params 290 ) 291 _raise_for_status(resp) 292 return _extract_items(resp.json(), AdminFeedbackRow)
Admin audit list: every reaction row on a challenge with denormalised user identity. Requires admin role.
294 def reset_question_attempts( 295 self, 296 team_id: str, 297 challenge_id: str, 298 question_id: str, 299 *, 300 reason: str = "", 301 ) -> QuestionAttemptResetInfo: 302 """Reset the per-question wrong-attempt counter for one team. 303 304 Writes a fresh baseline timestamp; the cap counter ignores all 305 wrong submissions before the new baseline, so the team gets a 306 fresh batch of attempts on this question without any audit 307 rows being deleted. Returns the new baseline row. 308 Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``.""" 309 resp = self._http.request( 310 "POST", 311 f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts", 312 json={"reason": reason}, 313 ) 314 _raise_for_status(resp) 315 return QuestionAttemptResetInfo.model_validate(resp.json())
Reset the per-question wrong-attempt counter for one team.
Writes a fresh baseline timestamp; the cap counter ignores all
wrong submissions before the new baseline, so the team gets a
fresh batch of attempts on this question without any audit
rows being deleted. Returns the new baseline row.
Wraps POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts.
317 def list_llm_budgets(self, *, competition_id: str = "") -> LlmBudgetPage: 318 """Who has spent what against the challenge-LLM gateway. 319 320 The reader for :meth:`reset_llm_budget` — without it an organiser 321 can only hand back an allowance to a team somebody already named. 322 ⚠️ ``gateway_configured`` is on the envelope because an empty 323 list otherwise reads as "nobody spent anything" when it means 324 "this deployment enforces no budgets", and those call for 325 opposite actions. 326 Wraps ``GET /admin/llm-budgets``.""" 327 params = {"competition_id": competition_id} if competition_id else None 328 resp = self._http.request("GET", "/admin/llm-budgets", params=params) 329 _raise_for_status(resp) 330 return LlmBudgetPage.model_validate(resp.json())
Who has spent what against the challenge-LLM gateway.
The reader for reset_llm_budget() — without it an organiser
can only hand back an allowance to a team somebody already named.
⚠️ gateway_configured is on the envelope because an empty
list otherwise reads as "nobody spent anything" when it means
"this deployment enforces no budgets", and those call for
opposite actions.
Wraps GET /admin/llm-budgets.
332 def deception_stats( 333 self, 334 *, 335 competition_id: str = "", 336 challenge_id: str = "", 337 since: datetime | None = None, 338 ) -> DeceptionStats: 339 """How often an assessing agent obeyed a directive planted in a 340 challenge's own output. 341 342 ⚠️ ``resisted`` is an **upper bound**, and the response carries 343 that caveat on the wire rather than leaving it to documentation: 344 the probe runs inside a container the player is invited to 345 compromise, so a missing compliance record is not evidence of 346 restraint. ``comply_rate`` is ``None`` rather than ``0.0`` when 347 nothing has been contacted — 0/0 rendered as 0% would assert 348 that no agent was fooled. 349 350 Nothing here is scored; it reports, it does not rule. 351 Wraps ``GET /admin/deception/stats``.""" 352 # ⚠️ ``since`` is the route's third filter and this wrapper used 353 # to drop it, so "what happened during *this* event" was 354 # unaskable from anything but a hand-rolled request — the shape 355 # ``admin competition update``'s missing flags already record. 356 params = { 357 k: v 358 for k, v in ( 359 ("competition_id", competition_id), 360 ("challenge_id", challenge_id), 361 ("since", since.isoformat() if since else ""), 362 ) 363 if v 364 } 365 resp = self._http.request("GET", "/admin/deception/stats", params=params or None) 366 _raise_for_status(resp) 367 return DeceptionStats.model_validate(resp.json())
How often an assessing agent obeyed a directive planted in a challenge's own output.
⚠️ resisted is an upper bound, and the response carries
that caveat on the wire rather than leaving it to documentation:
the probe runs inside a container the player is invited to
compromise, so a missing compliance record is not evidence of
restraint. comply_rate is None rather than 0.0 when
nothing has been contacted — 0/0 rendered as 0% would assert
that no agent was fooled.
Nothing here is scored; it reports, it does not rule.
Wraps GET /admin/deception/stats.
369 def reset_llm_budget( 370 self, 371 team_id: str, 372 competition_id: str, 373 challenge_id: str, 374 *, 375 reason: str = "", 376 ) -> LlmBudgetResetInfo: 377 """Give one team its LLM allowance back on one challenge. 378 379 Clears the gateway's live tally **and** the platform's durable 380 total — either alone un-does itself, so the route does both or 381 refuses. ⚠️ Always answers 200; read ``.outcome`` rather than the 382 status, because ``unreachable`` means nothing changed and is 383 worth retrying. 384 Wraps ``POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset``.""" 385 resp = self._http.request( 386 "POST", 387 f"/admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset", 388 json={"reason": reason}, 389 ) 390 _raise_for_status(resp) 391 return LlmBudgetResetInfo.model_validate(resp.json())
Give one team its LLM allowance back on one challenge.
Clears the gateway's live tally and the platform's durable
total — either alone un-does itself, so the route does both or
refuses. ⚠️ Always answers 200; read .outcome rather than the
status, because unreachable means nothing changed and is
worth retrying.
Wraps POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset.