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())
class AdminChallengesResource:
 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.

AdminChallengesResource(http: ctfy.sdk.base.BaseHttpClient)
35    def __init__(self, http: BaseHttpClient) -> None:
36        self._http = http
def export_harbor(self, challenge_id: str) -> bytes:
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.

def rescan(self) -> ctfy.server.models.AdminTaskInfo:
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.

def build(self, challenge_id: str) -> ctfy.server.models.AdminTaskInfo:
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.

def build_all( self, *, competition_id: str = '') -> ctfy.server.models.AdminTaskInfo:
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.

def build_push( self, *, bucket: str = '', challenge_ids: list[str] | None = None, node_id: str = '') -> ctfy.server.models.AdminTaskInfo:
 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.

def questions( self, challenge_id: str) -> ctfy.server.models.AdminChallengeQuestions:
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.

def blueprint( self, challenge_id: str) -> ctfy.server.models.ChallengeBlueprint:
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.

def range_progress( self, competition_id: str, challenge_id: str) -> ctfy.server.models.RangeProgress:
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.

def range_replay( self, competition_id: str, challenge_id: str, *, frames: int | None = None) -> ctfy.server.models.RangeReplay:
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.

def build_state(self) -> ctfy.server.models.ChallengeBuildStateResponse:
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.

def pull(self, challenge_id: str) -> ctfy.server.models.AdminTaskInfo:
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.

def pull_all( self, *, competition_id: str = '') -> ctfy.server.models.AdminTaskInfo:
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.

def pull_state(self) -> ctfy.server.models.ChallengePullStateResponse:
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.

def stats( self, offset: int = 0, limit: int = 50) -> list[ctfy.server.models.AdminChallengeStatsRow]:
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.

def solve_stats( self, *, competition_id: str = '', q: str = '', offset: int = 0, limit: int = 50) -> list[ctfy.server.models.AdminChallengeSolveStatsRow]:
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.

def timeseries( self, challenge_id: str, *, window: int = 86400, bucket: int = 3600) -> ctfy.server.models.AdminChallengeTimeseries:
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.

def latency_histogram(self) -> list[ctfy.server.models.AdminChallengeLatencyBucket]:
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).

def feedback( self, challenge_id: str, *, competition_id: str = '') -> ctfy.sdk._helpers.PagedList[ctfy.server.models.AdminFeedbackRow]:
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.

def reset_question_attempts( self, team_id: str, challenge_id: str, question_id: str, *, reason: str = '') -> ctfy.server.models.QuestionAttemptResetInfo:
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.

def list_llm_budgets( self, *, competition_id: str = '') -> ctfy.server.models.LlmBudgetPage:
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.

def deception_stats( self, *, competition_id: str = '', challenge_id: str = '', since: datetime.datetime | None = None) -> ctfy.server.models.DeceptionStats:
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.

def reset_llm_budget( self, team_id: str, competition_id: str, challenge_id: str, *, reason: str = '') -> ctfy.server.models.LlmBudgetResetInfo:
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.