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

Per-challenge attempt / solve / first-blood counts.

def timeseries( self, challenge_id: str, *, window: int = 86400, bucket: int = 3600) -> ctfy.server.models.AdminChallengeTimeseries:
230    def timeseries(
231        self,
232        challenge_id: str,
233        *,
234        window: int = 86400,
235        bucket: int = 3600,
236    ) -> AdminChallengeTimeseries:
237        """Per-challenge timeseries for the row-level drawer on
238        ``/admin/challenges``."""
239        resp = self._http.request(
240            "GET",
241            f"/admin/challenges/{challenge_id}/timeseries",
242            params={"window": window, "bucket": bucket},
243        )
244        _raise_for_status(resp)
245        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]:
247    def latency_histogram(self) -> list[AdminChallengeLatencyBucket]:
248        """Instance-start latency distribution across challenges (the
249        admin challenges latency chart)."""
250        resp = self._http.request("GET", "/admin/challenges/latency-histogram")
251        _raise_for_status(resp)
252        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]:
254    def feedback(
255        self, challenge_id: str, *, competition_id: str = ""
256    ) -> PagedList[AdminFeedbackRow]:
257        """Admin audit list: every reaction row on a challenge with
258        denormalised user identity. Requires admin role."""
259        params: dict[str, Any] = {}
260        if competition_id:
261            params["competition_id"] = competition_id
262        resp = self._http.request(
263            "GET", f"/admin/challenges/{challenge_id}/feedback", params=params
264        )
265        _raise_for_status(resp)
266        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:
268    def reset_question_attempts(
269        self,
270        team_id: str,
271        challenge_id: str,
272        question_id: str,
273        *,
274        reason: str = "",
275    ) -> QuestionAttemptResetInfo:
276        """Reset the per-question wrong-attempt counter for one team.
277
278        Writes a fresh baseline timestamp; the cap counter ignores all
279        wrong submissions before the new baseline, so the team gets a
280        fresh batch of attempts on this question without any audit
281        rows being deleted. Returns the new baseline row.
282        Wraps ``POST /admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts``."""
283        resp = self._http.request(
284            "POST",
285            f"/admin/teams/{team_id}/questions/{challenge_id}/{question_id}/reset-attempts",
286            json={"reason": reason},
287        )
288        _raise_for_status(resp)
289        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:
291    def list_llm_budgets(self, *, competition_id: str = "") -> LlmBudgetPage:
292        """Who has spent what against the challenge-LLM gateway.
293
294        The reader for :meth:`reset_llm_budget` — without it an organiser
295        can only hand back an allowance to a team somebody already named.
296        ⚠️ ``gateway_configured`` is on the envelope because an empty
297        list otherwise reads as "nobody spent anything" when it means
298        "this deployment enforces no budgets", and those call for
299        opposite actions.
300        Wraps ``GET /admin/llm-budgets``."""
301        params = {"competition_id": competition_id} if competition_id else None
302        resp = self._http.request("GET", "/admin/llm-budgets", params=params)
303        _raise_for_status(resp)
304        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:
306    def deception_stats(
307        self,
308        *,
309        competition_id: str = "",
310        challenge_id: str = "",
311        since: datetime | None = None,
312    ) -> DeceptionStats:
313        """How often an assessing agent obeyed a directive planted in a
314        challenge's own output.
315
316        ⚠️ ``resisted`` is an **upper bound**, and the response carries
317        that caveat on the wire rather than leaving it to documentation:
318        the probe runs inside a container the player is invited to
319        compromise, so a missing compliance record is not evidence of
320        restraint. ``comply_rate`` is ``None`` rather than ``0.0`` when
321        nothing has been contacted — 0/0 rendered as 0% would assert
322        that no agent was fooled.
323
324        Nothing here is scored; it reports, it does not rule.
325        Wraps ``GET /admin/deception/stats``."""
326        # ⚠️ ``since`` is the route's third filter and this wrapper used
327        # to drop it, so "what happened during *this* event" was
328        # unaskable from anything but a hand-rolled request — the shape
329        # ``admin competition update``'s missing flags already record.
330        params = {
331            k: v
332            for k, v in (
333                ("competition_id", competition_id),
334                ("challenge_id", challenge_id),
335                ("since", since.isoformat() if since else ""),
336            )
337            if v
338        }
339        resp = self._http.request("GET", "/admin/deception/stats", params=params or None)
340        _raise_for_status(resp)
341        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:
343    def reset_llm_budget(
344        self,
345        team_id: str,
346        competition_id: str,
347        challenge_id: str,
348        *,
349        reason: str = "",
350    ) -> LlmBudgetResetInfo:
351        """Give one team its LLM allowance back on one challenge.
352
353        Clears the gateway's live tally **and** the platform's durable
354        total — either alone un-does itself, so the route does both or
355        refuses. ⚠️ Always answers 200; read ``.outcome`` rather than the
356        status, because ``unreachable`` means nothing changed and is
357        worth retrying.
358        Wraps ``POST /admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset``."""
359        resp = self._http.request(
360            "POST",
361            f"/admin/teams/{team_id}/llm-budget/{competition_id}/{challenge_id}/reset",
362            json={"reason": reason},
363        )
364        _raise_for_status(resp)
365        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.