ctfy.sdk.resources.submissions

client.submissions — graded submit, oracle verify, QA challenges.

  1"""``client.submissions`` — graded submit, oracle verify, QA challenges."""
  2
  3from __future__ import annotations
  4
  5import base64
  6import builtins
  7from typing import Any
  8
  9from ctfy.core.judge_budget import player_wait_s
 10from ctfy.core.state.models import SubmissionState
 11from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
 12from ctfy.sdk.base import RETRY_UNSENT, BaseHttpClient
 13from ctfy.server.models import (
 14    CheckAnswerResponse,
 15    DefenceSource,
 16    PatchSubmissionInfo,
 17    QaChallengeView,
 18    SubmissionResponse,
 19    VerifyAnswerResponse,
 20)
 21
 22
 23class SubmissionsResource:
 24    """Answer submission (graded), oracle verification (no record), and QA."""
 25
 26    def __init__(self, http: BaseHttpClient) -> None:
 27        self._http = http
 28
 29    def submit(
 30        self,
 31        instance_id: str,
 32        answer: str | builtins.list[str],
 33        *,
 34        question_id: str = "flag",
 35    ) -> SubmissionResponse:
 36        """Submit ``answer`` against ``question_id`` on ``instance_id``.
 37
 38        The server reads challenge_id, team_id and competition_id off
 39        the instance row; the calling user must be on the same team
 40        that started this instance (in the same competition).
 41
 42        ``question_id`` defaults to ``"flag"`` for single-question
 43        challenges (the most common case). Multi-question challenges
 44        (see :class:`ChallengeInfo.questions`) require an explicit id.
 45        ``answer`` is a single string for dynamic / static /
 46        single_select questions and a list of strings for multi_select.
 47        """
 48        resp = self._http.request(
 49            "POST",
 50            "/submissions",
 51            json={
 52                "instance_id": instance_id,
 53                "question_id": question_id,
 54                "answer": answer,
 55            },
 56            # ⚠️ A judge-graded question runs its judge inside this request
 57            # (#3169), for up to the node's ceiling: wait that out, and never
 58            # re-send after a read timeout — the platform has the submission,
 59            # and a second POST is a second judged (and capped) attempt.
 60            timeout=player_wait_s(),
 61            retry_on=RETRY_UNSENT,
 62        )
 63        _raise_for_status(resp)
 64        return SubmissionResponse.model_validate(resp.json())
 65
 66    def verify(
 67        self,
 68        instance_id: str,
 69        answer: str | builtins.list[str],
 70        *,
 71        question_id: str = "flag",
 72    ) -> VerifyAnswerResponse:
 73        """Server-side answer verification — no submission record.
 74
 75        Same grader as :meth:`submit` (mode-aware,
 76        ``$ANSWER_<UPPER_ID>`` tolerance for dynamic, set-equal for
 77        ``multi_select``, etc.) but writes nothing to the audit log.
 78        Use for oracle-style probes; for graded competitions use
 79        :meth:`submit`.
 80
 81        Returns :class:`VerifyAnswerResponse` with ``.correct`` and
 82        ``.question_id`` (the latter echoed on a correct match).
 83        """
 84        resp = self._http.request(
 85            "POST",
 86            f"/instances/{instance_id}/verify-answer",
 87            json={"question_id": question_id, "answer": answer},
 88        )
 89        _raise_for_status(resp)
 90        return VerifyAnswerResponse.model_validate(resp.json())
 91
 92    def check(
 93        self,
 94        instance_id: str,
 95        *,
 96        question_id: str = "flag",
 97    ) -> CheckAnswerResponse:
 98        """Run the checker for a *checker-mode* question (proof-of-exploitation).
 99
100        The platform execs the question's author-supplied checker inside
101        the trusted judge sidecar and records a solve iff the sidecar emits
102        this instance's minted token — the player submits no answer string.
103        Fails closed: a failed / timed-out / missing checker returns
104        ``passed=False`` and records nothing.
105
106        Returns :class:`CheckAnswerResponse` with ``.passed`` and, on a
107        fresh solve, ``.solve_rank`` / ``.challenge_fully_solved``.
108        """
109        resp = self._http.request(
110            "POST",
111            f"/instances/{instance_id}/check",
112            json={"question_id": question_id},
113            # The judge runs inside this request — see :meth:`submit` (#3169).
114            timeout=player_wait_s(),
115            retry_on=RETRY_UNSENT,
116        )
117        _raise_for_status(resp)
118        return CheckAnswerResponse.model_validate(resp.json())
119
120    def list(
121        self, challenge_id: str = "", offset: int = 0, limit: int = 50
122    ) -> PagedList[SubmissionState]:
123        """List current team's submissions."""
124        params: dict[str, Any] = {"offset": offset, "limit": limit}
125        if challenge_id:
126            params["challenge_id"] = challenge_id
127        resp = self._http.request("GET", "/submissions", params=params)
128        _raise_for_status(resp)
129        return _extract_items(resp.json(), SubmissionState)
130
131    def submit_qa(
132        self,
133        challenge_id: str,
134        competition_id: str,
135        answer: str | builtins.list[str],
136        *,
137        question_id: str = "answer",
138    ) -> SubmissionResponse:
139        """Submit ``answer`` against a pure question-answer challenge.
140
141        Unlike :meth:`submit`, QA challenges have no instance — the
142        caller passes ``challenge_id`` + ``competition_id`` explicitly,
143        and the server resolves the team for the competition via the
144        standard auth helper. ``question_id`` defaults to ``"answer"``
145        (the canonical id for MCQ items produced by ``ctfy-admin
146        challenge import``).
147        """
148        resp = self._http.request(
149            "POST",
150            "/qa/submissions",
151            json={
152                "challenge_id": challenge_id,
153                "competition_id": competition_id,
154                "question_id": question_id,
155                "answer": answer,
156            },
157        )
158        _raise_for_status(resp)
159        return SubmissionResponse.model_validate(resp.json())
160
161    def list_qa_challenges(self, competition_id: str) -> builtins.list[QaChallengeView]:
162        """All QA challenges in scope of one competition + team status.
163
164        Returns one :class:`QaChallengeView` per challenge with the
165        calling team's per-question solve / wrong-attempt state
166        pre-joined. The quiz UI renders the full surface from this
167        single response.
168        """
169        resp = self._http.request(
170            "GET", "/qa/challenges", params={"competition_id": competition_id}
171        )
172        _raise_for_status(resp)
173        data = resp.json()
174        return [QaChallengeView.model_validate(item) for item in data]
175
176
177class PatchesResource:
178    """``client.patches`` — AWD+ defence submissions.
179
180    Submitting is not judging: the platform records the patch and a
181    worker builds and rules on it later, so :meth:`submit` returns a
182    ``pending`` row and :meth:`list` is how a client watches for the
183    verdict.
184    """
185
186    def __init__(self, http: BaseHttpClient) -> None:
187        self._http = http
188
189    def source(self, challenge_id: str) -> dict[str, bytes]:
190        """The patchable source, decoded — what a defender starts from.
191
192        AWD+ gives every team the same vulnerable service *and its
193        source*; this is that. Returns exactly the challenge's
194        ``patch.targets``, keyed by challenge-relative path, and
195        deliberately **not** the benchmark tree — the reference exploit
196        and the checker live there too.
197
198        Decoded here so it pairs with :meth:`submit`, which takes bytes:
199        ``patches.submit(inst, patches.source(chal) | {p: fixed})`` is
200        the whole round trip.
201
202        Challenge-scoped, so it works before an instance exists and
203        after the event ends — which is what makes upsolve possible.
204        """
205        resp = self._http.request("GET", f"/challenges/{challenge_id}/defence/source")
206        _raise_for_status(resp)
207        payload = DefenceSource.model_validate(resp.json())
208        return {path: base64.b64decode(blob) for path, blob in payload.files.items()}
209
210    def submit(self, instance_id: str, files: dict[str, bytes]) -> PatchSubmissionInfo:
211        """Submit replacement files for ``instance_id``'s challenge.
212
213        ``files`` maps a **challenge-relative** path to its new bytes.
214        Only paths the challenge's ``patch.targets`` whitelist covers are
215        accepted; anything else is refused on this call rather than at
216        judge time, so the caller learns which file was rejected without
217        waiting for a build.
218
219        Encoding is handled here: the wire carries base64 because a
220        patch target may legitimately be binary.
221        """
222        resp = self._http.request(
223            "POST",
224            "/patches",
225            json={
226                "instance_id": instance_id,
227                "files": {
228                    path: base64.b64encode(content).decode() for path, content in files.items()
229                },
230            },
231        )
232        _raise_for_status(resp)
233        return PatchSubmissionInfo.model_validate(resp.json())
234
235    def submit_for_challenge(
236        self,
237        challenge_id: str,
238        files: dict[str, bytes],
239        *,
240        competition_id: str = "",
241    ) -> PatchSubmissionInfo:
242        """Submit a patch with **no box running**.
243
244        :meth:`source` has always worked before an instance exists and
245        after the event ends — that is what its own docstring calls the
246        thing that makes upsolve possible — while :meth:`submit`
247        required an instance, so the round trip it advertises had a
248        broken second half. This is that half: upsolve, virtual
249        participation, and a defender fixing a challenge after their box
250        expired.
251
252        A separate method rather than an optional ``instance_id``: the
253        two are different addressings, not a default, and the split
254        keeps them as obvious as :meth:`submit` versus
255        :meth:`submit_live`.
256
257        The credited team comes from the caller's own membership for
258        ``competition_id`` — there is no team on the wire — so this is
259        no weaker than naming a box you own.
260        """
261        resp = self._http.request(
262            "POST",
263            "/patches",
264            json={
265                "challenge_id": challenge_id,
266                "competition_id": competition_id,
267                "files": {
268                    path: base64.b64encode(content).decode() for path, content in files.items()
269                },
270            },
271        )
272        _raise_for_status(resp)
273        return PatchSubmissionInfo.model_validate(resp.json())
274
275    def submit_live(self, instance_id: str) -> PatchSubmissionInfo:
276        """Submit whatever is *currently in the box* — no payload.
277
278        The channel an AWD player actually uses: SSH in, edit in place,
279        then ask the platform to read the result back and judge it. The
280        platform decides which paths to read, from the challenge's
281        pristine source tree — the box never gets to nominate what is
282        graded.
283
284        Requires the challenge to declare a ``patch.live`` block;
285        without one the platform answers ``patch_live_unsupported`` and
286        the upload form is the only channel.
287        """
288        resp = self._http.request(
289            "POST",
290            "/patches",
291            json={"instance_id": instance_id, "source": "live"},
292        )
293        _raise_for_status(resp)
294        return PatchSubmissionInfo.model_validate(resp.json())
295
296    def list(self, *, challenge_id: str = "") -> builtins.list[PatchSubmissionInfo]:
297        """This team's defence history, newest first.
298
299        Carries each submission's status and verdict but not its code —
300        the stored row keeps the bytes so a verdict can be re-derived,
301        while this is a status view that gets polled.
302        """
303        params = {"challenge_id": challenge_id} if challenge_id else {}
304        resp = self._http.request("GET", "/patches", params=params)
305        _raise_for_status(resp)
306        return [PatchSubmissionInfo.model_validate(row) for row in resp.json()]
class SubmissionsResource:
 24class SubmissionsResource:
 25    """Answer submission (graded), oracle verification (no record), and QA."""
 26
 27    def __init__(self, http: BaseHttpClient) -> None:
 28        self._http = http
 29
 30    def submit(
 31        self,
 32        instance_id: str,
 33        answer: str | builtins.list[str],
 34        *,
 35        question_id: str = "flag",
 36    ) -> SubmissionResponse:
 37        """Submit ``answer`` against ``question_id`` on ``instance_id``.
 38
 39        The server reads challenge_id, team_id and competition_id off
 40        the instance row; the calling user must be on the same team
 41        that started this instance (in the same competition).
 42
 43        ``question_id`` defaults to ``"flag"`` for single-question
 44        challenges (the most common case). Multi-question challenges
 45        (see :class:`ChallengeInfo.questions`) require an explicit id.
 46        ``answer`` is a single string for dynamic / static /
 47        single_select questions and a list of strings for multi_select.
 48        """
 49        resp = self._http.request(
 50            "POST",
 51            "/submissions",
 52            json={
 53                "instance_id": instance_id,
 54                "question_id": question_id,
 55                "answer": answer,
 56            },
 57            # ⚠️ A judge-graded question runs its judge inside this request
 58            # (#3169), for up to the node's ceiling: wait that out, and never
 59            # re-send after a read timeout — the platform has the submission,
 60            # and a second POST is a second judged (and capped) attempt.
 61            timeout=player_wait_s(),
 62            retry_on=RETRY_UNSENT,
 63        )
 64        _raise_for_status(resp)
 65        return SubmissionResponse.model_validate(resp.json())
 66
 67    def verify(
 68        self,
 69        instance_id: str,
 70        answer: str | builtins.list[str],
 71        *,
 72        question_id: str = "flag",
 73    ) -> VerifyAnswerResponse:
 74        """Server-side answer verification — no submission record.
 75
 76        Same grader as :meth:`submit` (mode-aware,
 77        ``$ANSWER_<UPPER_ID>`` tolerance for dynamic, set-equal for
 78        ``multi_select``, etc.) but writes nothing to the audit log.
 79        Use for oracle-style probes; for graded competitions use
 80        :meth:`submit`.
 81
 82        Returns :class:`VerifyAnswerResponse` with ``.correct`` and
 83        ``.question_id`` (the latter echoed on a correct match).
 84        """
 85        resp = self._http.request(
 86            "POST",
 87            f"/instances/{instance_id}/verify-answer",
 88            json={"question_id": question_id, "answer": answer},
 89        )
 90        _raise_for_status(resp)
 91        return VerifyAnswerResponse.model_validate(resp.json())
 92
 93    def check(
 94        self,
 95        instance_id: str,
 96        *,
 97        question_id: str = "flag",
 98    ) -> CheckAnswerResponse:
 99        """Run the checker for a *checker-mode* question (proof-of-exploitation).
100
101        The platform execs the question's author-supplied checker inside
102        the trusted judge sidecar and records a solve iff the sidecar emits
103        this instance's minted token — the player submits no answer string.
104        Fails closed: a failed / timed-out / missing checker returns
105        ``passed=False`` and records nothing.
106
107        Returns :class:`CheckAnswerResponse` with ``.passed`` and, on a
108        fresh solve, ``.solve_rank`` / ``.challenge_fully_solved``.
109        """
110        resp = self._http.request(
111            "POST",
112            f"/instances/{instance_id}/check",
113            json={"question_id": question_id},
114            # The judge runs inside this request — see :meth:`submit` (#3169).
115            timeout=player_wait_s(),
116            retry_on=RETRY_UNSENT,
117        )
118        _raise_for_status(resp)
119        return CheckAnswerResponse.model_validate(resp.json())
120
121    def list(
122        self, challenge_id: str = "", offset: int = 0, limit: int = 50
123    ) -> PagedList[SubmissionState]:
124        """List current team's submissions."""
125        params: dict[str, Any] = {"offset": offset, "limit": limit}
126        if challenge_id:
127            params["challenge_id"] = challenge_id
128        resp = self._http.request("GET", "/submissions", params=params)
129        _raise_for_status(resp)
130        return _extract_items(resp.json(), SubmissionState)
131
132    def submit_qa(
133        self,
134        challenge_id: str,
135        competition_id: str,
136        answer: str | builtins.list[str],
137        *,
138        question_id: str = "answer",
139    ) -> SubmissionResponse:
140        """Submit ``answer`` against a pure question-answer challenge.
141
142        Unlike :meth:`submit`, QA challenges have no instance — the
143        caller passes ``challenge_id`` + ``competition_id`` explicitly,
144        and the server resolves the team for the competition via the
145        standard auth helper. ``question_id`` defaults to ``"answer"``
146        (the canonical id for MCQ items produced by ``ctfy-admin
147        challenge import``).
148        """
149        resp = self._http.request(
150            "POST",
151            "/qa/submissions",
152            json={
153                "challenge_id": challenge_id,
154                "competition_id": competition_id,
155                "question_id": question_id,
156                "answer": answer,
157            },
158        )
159        _raise_for_status(resp)
160        return SubmissionResponse.model_validate(resp.json())
161
162    def list_qa_challenges(self, competition_id: str) -> builtins.list[QaChallengeView]:
163        """All QA challenges in scope of one competition + team status.
164
165        Returns one :class:`QaChallengeView` per challenge with the
166        calling team's per-question solve / wrong-attempt state
167        pre-joined. The quiz UI renders the full surface from this
168        single response.
169        """
170        resp = self._http.request(
171            "GET", "/qa/challenges", params={"competition_id": competition_id}
172        )
173        _raise_for_status(resp)
174        data = resp.json()
175        return [QaChallengeView.model_validate(item) for item in data]

Answer submission (graded), oracle verification (no record), and QA.

SubmissionsResource(http: ctfy.sdk.base.BaseHttpClient)
27    def __init__(self, http: BaseHttpClient) -> None:
28        self._http = http
def submit( self, instance_id: str, answer: str | list[str], *, question_id: str = 'flag') -> ctfy.server.models.SubmissionResponse:
30    def submit(
31        self,
32        instance_id: str,
33        answer: str | builtins.list[str],
34        *,
35        question_id: str = "flag",
36    ) -> SubmissionResponse:
37        """Submit ``answer`` against ``question_id`` on ``instance_id``.
38
39        The server reads challenge_id, team_id and competition_id off
40        the instance row; the calling user must be on the same team
41        that started this instance (in the same competition).
42
43        ``question_id`` defaults to ``"flag"`` for single-question
44        challenges (the most common case). Multi-question challenges
45        (see :class:`ChallengeInfo.questions`) require an explicit id.
46        ``answer`` is a single string for dynamic / static /
47        single_select questions and a list of strings for multi_select.
48        """
49        resp = self._http.request(
50            "POST",
51            "/submissions",
52            json={
53                "instance_id": instance_id,
54                "question_id": question_id,
55                "answer": answer,
56            },
57            # ⚠️ A judge-graded question runs its judge inside this request
58            # (#3169), for up to the node's ceiling: wait that out, and never
59            # re-send after a read timeout — the platform has the submission,
60            # and a second POST is a second judged (and capped) attempt.
61            timeout=player_wait_s(),
62            retry_on=RETRY_UNSENT,
63        )
64        _raise_for_status(resp)
65        return SubmissionResponse.model_validate(resp.json())

Submit answer against question_id on instance_id.

The server reads challenge_id, team_id and competition_id off the instance row; the calling user must be on the same team that started this instance (in the same competition).

question_id defaults to "flag" for single-question challenges (the most common case). Multi-question challenges (see ChallengeInfo.questions) require an explicit id. answer is a single string for dynamic / static / single_select questions and a list of strings for multi_select.

def verify( self, instance_id: str, answer: str | list[str], *, question_id: str = 'flag') -> ctfy.server.models.VerifyAnswerResponse:
67    def verify(
68        self,
69        instance_id: str,
70        answer: str | builtins.list[str],
71        *,
72        question_id: str = "flag",
73    ) -> VerifyAnswerResponse:
74        """Server-side answer verification — no submission record.
75
76        Same grader as :meth:`submit` (mode-aware,
77        ``$ANSWER_<UPPER_ID>`` tolerance for dynamic, set-equal for
78        ``multi_select``, etc.) but writes nothing to the audit log.
79        Use for oracle-style probes; for graded competitions use
80        :meth:`submit`.
81
82        Returns :class:`VerifyAnswerResponse` with ``.correct`` and
83        ``.question_id`` (the latter echoed on a correct match).
84        """
85        resp = self._http.request(
86            "POST",
87            f"/instances/{instance_id}/verify-answer",
88            json={"question_id": question_id, "answer": answer},
89        )
90        _raise_for_status(resp)
91        return VerifyAnswerResponse.model_validate(resp.json())

Server-side answer verification — no submission record.

Same grader as submit() (mode-aware, $ANSWER_<UPPER_ID> tolerance for dynamic, set-equal for multi_select, etc.) but writes nothing to the audit log. Use for oracle-style probes; for graded competitions use submit().

Returns VerifyAnswerResponse with .correct and .question_id (the latter echoed on a correct match).

def check( self, instance_id: str, *, question_id: str = 'flag') -> ctfy.server.models.CheckAnswerResponse:
 93    def check(
 94        self,
 95        instance_id: str,
 96        *,
 97        question_id: str = "flag",
 98    ) -> CheckAnswerResponse:
 99        """Run the checker for a *checker-mode* question (proof-of-exploitation).
100
101        The platform execs the question's author-supplied checker inside
102        the trusted judge sidecar and records a solve iff the sidecar emits
103        this instance's minted token — the player submits no answer string.
104        Fails closed: a failed / timed-out / missing checker returns
105        ``passed=False`` and records nothing.
106
107        Returns :class:`CheckAnswerResponse` with ``.passed`` and, on a
108        fresh solve, ``.solve_rank`` / ``.challenge_fully_solved``.
109        """
110        resp = self._http.request(
111            "POST",
112            f"/instances/{instance_id}/check",
113            json={"question_id": question_id},
114            # The judge runs inside this request — see :meth:`submit` (#3169).
115            timeout=player_wait_s(),
116            retry_on=RETRY_UNSENT,
117        )
118        _raise_for_status(resp)
119        return CheckAnswerResponse.model_validate(resp.json())

Run the checker for a checker-mode question (proof-of-exploitation).

The platform execs the question's author-supplied checker inside the trusted judge sidecar and records a solve iff the sidecar emits this instance's minted token — the player submits no answer string. Fails closed: a failed / timed-out / missing checker returns passed=False and records nothing.

Returns CheckAnswerResponse with .passed and, on a fresh solve, .solve_rank / .challenge_fully_solved.

def list( self, challenge_id: str = '', offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.core.state.models.SubmissionState]:
121    def list(
122        self, challenge_id: str = "", offset: int = 0, limit: int = 50
123    ) -> PagedList[SubmissionState]:
124        """List current team's submissions."""
125        params: dict[str, Any] = {"offset": offset, "limit": limit}
126        if challenge_id:
127            params["challenge_id"] = challenge_id
128        resp = self._http.request("GET", "/submissions", params=params)
129        _raise_for_status(resp)
130        return _extract_items(resp.json(), SubmissionState)

List current team's submissions.

def submit_qa( self, challenge_id: str, competition_id: str, answer: str | list[str], *, question_id: str = 'answer') -> ctfy.server.models.SubmissionResponse:
132    def submit_qa(
133        self,
134        challenge_id: str,
135        competition_id: str,
136        answer: str | builtins.list[str],
137        *,
138        question_id: str = "answer",
139    ) -> SubmissionResponse:
140        """Submit ``answer`` against a pure question-answer challenge.
141
142        Unlike :meth:`submit`, QA challenges have no instance — the
143        caller passes ``challenge_id`` + ``competition_id`` explicitly,
144        and the server resolves the team for the competition via the
145        standard auth helper. ``question_id`` defaults to ``"answer"``
146        (the canonical id for MCQ items produced by ``ctfy-admin
147        challenge import``).
148        """
149        resp = self._http.request(
150            "POST",
151            "/qa/submissions",
152            json={
153                "challenge_id": challenge_id,
154                "competition_id": competition_id,
155                "question_id": question_id,
156                "answer": answer,
157            },
158        )
159        _raise_for_status(resp)
160        return SubmissionResponse.model_validate(resp.json())

Submit answer against a pure question-answer challenge.

Unlike submit(), QA challenges have no instance — the caller passes challenge_id + competition_id explicitly, and the server resolves the team for the competition via the standard auth helper. question_id defaults to "answer" (the canonical id for MCQ items produced by ctfy-admin challenge import).

def list_qa_challenges( self, competition_id: str) -> list[ctfy.server.models.QaChallengeView]:
162    def list_qa_challenges(self, competition_id: str) -> builtins.list[QaChallengeView]:
163        """All QA challenges in scope of one competition + team status.
164
165        Returns one :class:`QaChallengeView` per challenge with the
166        calling team's per-question solve / wrong-attempt state
167        pre-joined. The quiz UI renders the full surface from this
168        single response.
169        """
170        resp = self._http.request(
171            "GET", "/qa/challenges", params={"competition_id": competition_id}
172        )
173        _raise_for_status(resp)
174        data = resp.json()
175        return [QaChallengeView.model_validate(item) for item in data]

All QA challenges in scope of one competition + team status.

Returns one QaChallengeView per challenge with the calling team's per-question solve / wrong-attempt state pre-joined. The quiz UI renders the full surface from this single response.

class PatchesResource:
178class PatchesResource:
179    """``client.patches`` — AWD+ defence submissions.
180
181    Submitting is not judging: the platform records the patch and a
182    worker builds and rules on it later, so :meth:`submit` returns a
183    ``pending`` row and :meth:`list` is how a client watches for the
184    verdict.
185    """
186
187    def __init__(self, http: BaseHttpClient) -> None:
188        self._http = http
189
190    def source(self, challenge_id: str) -> dict[str, bytes]:
191        """The patchable source, decoded — what a defender starts from.
192
193        AWD+ gives every team the same vulnerable service *and its
194        source*; this is that. Returns exactly the challenge's
195        ``patch.targets``, keyed by challenge-relative path, and
196        deliberately **not** the benchmark tree — the reference exploit
197        and the checker live there too.
198
199        Decoded here so it pairs with :meth:`submit`, which takes bytes:
200        ``patches.submit(inst, patches.source(chal) | {p: fixed})`` is
201        the whole round trip.
202
203        Challenge-scoped, so it works before an instance exists and
204        after the event ends — which is what makes upsolve possible.
205        """
206        resp = self._http.request("GET", f"/challenges/{challenge_id}/defence/source")
207        _raise_for_status(resp)
208        payload = DefenceSource.model_validate(resp.json())
209        return {path: base64.b64decode(blob) for path, blob in payload.files.items()}
210
211    def submit(self, instance_id: str, files: dict[str, bytes]) -> PatchSubmissionInfo:
212        """Submit replacement files for ``instance_id``'s challenge.
213
214        ``files`` maps a **challenge-relative** path to its new bytes.
215        Only paths the challenge's ``patch.targets`` whitelist covers are
216        accepted; anything else is refused on this call rather than at
217        judge time, so the caller learns which file was rejected without
218        waiting for a build.
219
220        Encoding is handled here: the wire carries base64 because a
221        patch target may legitimately be binary.
222        """
223        resp = self._http.request(
224            "POST",
225            "/patches",
226            json={
227                "instance_id": instance_id,
228                "files": {
229                    path: base64.b64encode(content).decode() for path, content in files.items()
230                },
231            },
232        )
233        _raise_for_status(resp)
234        return PatchSubmissionInfo.model_validate(resp.json())
235
236    def submit_for_challenge(
237        self,
238        challenge_id: str,
239        files: dict[str, bytes],
240        *,
241        competition_id: str = "",
242    ) -> PatchSubmissionInfo:
243        """Submit a patch with **no box running**.
244
245        :meth:`source` has always worked before an instance exists and
246        after the event ends — that is what its own docstring calls the
247        thing that makes upsolve possible — while :meth:`submit`
248        required an instance, so the round trip it advertises had a
249        broken second half. This is that half: upsolve, virtual
250        participation, and a defender fixing a challenge after their box
251        expired.
252
253        A separate method rather than an optional ``instance_id``: the
254        two are different addressings, not a default, and the split
255        keeps them as obvious as :meth:`submit` versus
256        :meth:`submit_live`.
257
258        The credited team comes from the caller's own membership for
259        ``competition_id`` — there is no team on the wire — so this is
260        no weaker than naming a box you own.
261        """
262        resp = self._http.request(
263            "POST",
264            "/patches",
265            json={
266                "challenge_id": challenge_id,
267                "competition_id": competition_id,
268                "files": {
269                    path: base64.b64encode(content).decode() for path, content in files.items()
270                },
271            },
272        )
273        _raise_for_status(resp)
274        return PatchSubmissionInfo.model_validate(resp.json())
275
276    def submit_live(self, instance_id: str) -> PatchSubmissionInfo:
277        """Submit whatever is *currently in the box* — no payload.
278
279        The channel an AWD player actually uses: SSH in, edit in place,
280        then ask the platform to read the result back and judge it. The
281        platform decides which paths to read, from the challenge's
282        pristine source tree — the box never gets to nominate what is
283        graded.
284
285        Requires the challenge to declare a ``patch.live`` block;
286        without one the platform answers ``patch_live_unsupported`` and
287        the upload form is the only channel.
288        """
289        resp = self._http.request(
290            "POST",
291            "/patches",
292            json={"instance_id": instance_id, "source": "live"},
293        )
294        _raise_for_status(resp)
295        return PatchSubmissionInfo.model_validate(resp.json())
296
297    def list(self, *, challenge_id: str = "") -> builtins.list[PatchSubmissionInfo]:
298        """This team's defence history, newest first.
299
300        Carries each submission's status and verdict but not its code —
301        the stored row keeps the bytes so a verdict can be re-derived,
302        while this is a status view that gets polled.
303        """
304        params = {"challenge_id": challenge_id} if challenge_id else {}
305        resp = self._http.request("GET", "/patches", params=params)
306        _raise_for_status(resp)
307        return [PatchSubmissionInfo.model_validate(row) for row in resp.json()]

client.patches — AWD+ defence submissions.

Submitting is not judging: the platform records the patch and a worker builds and rules on it later, so submit() returns a pending row and list() is how a client watches for the verdict.

PatchesResource(http: ctfy.sdk.base.BaseHttpClient)
187    def __init__(self, http: BaseHttpClient) -> None:
188        self._http = http
def source(self, challenge_id: str) -> dict[str, bytes]:
190    def source(self, challenge_id: str) -> dict[str, bytes]:
191        """The patchable source, decoded — what a defender starts from.
192
193        AWD+ gives every team the same vulnerable service *and its
194        source*; this is that. Returns exactly the challenge's
195        ``patch.targets``, keyed by challenge-relative path, and
196        deliberately **not** the benchmark tree — the reference exploit
197        and the checker live there too.
198
199        Decoded here so it pairs with :meth:`submit`, which takes bytes:
200        ``patches.submit(inst, patches.source(chal) | {p: fixed})`` is
201        the whole round trip.
202
203        Challenge-scoped, so it works before an instance exists and
204        after the event ends — which is what makes upsolve possible.
205        """
206        resp = self._http.request("GET", f"/challenges/{challenge_id}/defence/source")
207        _raise_for_status(resp)
208        payload = DefenceSource.model_validate(resp.json())
209        return {path: base64.b64decode(blob) for path, blob in payload.files.items()}

The patchable source, decoded — what a defender starts from.

AWD+ gives every team the same vulnerable service and its source; this is that. Returns exactly the challenge's patch.targets, keyed by challenge-relative path, and deliberately not the benchmark tree — the reference exploit and the checker live there too.

Decoded here so it pairs with submit(), which takes bytes: patches.submit(inst, patches.source(chal) | {p: fixed}) is the whole round trip.

Challenge-scoped, so it works before an instance exists and after the event ends — which is what makes upsolve possible.

def submit( self, instance_id: str, files: dict[str, bytes]) -> ctfy.server.models.PatchSubmissionInfo:
211    def submit(self, instance_id: str, files: dict[str, bytes]) -> PatchSubmissionInfo:
212        """Submit replacement files for ``instance_id``'s challenge.
213
214        ``files`` maps a **challenge-relative** path to its new bytes.
215        Only paths the challenge's ``patch.targets`` whitelist covers are
216        accepted; anything else is refused on this call rather than at
217        judge time, so the caller learns which file was rejected without
218        waiting for a build.
219
220        Encoding is handled here: the wire carries base64 because a
221        patch target may legitimately be binary.
222        """
223        resp = self._http.request(
224            "POST",
225            "/patches",
226            json={
227                "instance_id": instance_id,
228                "files": {
229                    path: base64.b64encode(content).decode() for path, content in files.items()
230                },
231            },
232        )
233        _raise_for_status(resp)
234        return PatchSubmissionInfo.model_validate(resp.json())

Submit replacement files for instance_id's challenge.

files maps a challenge-relative path to its new bytes. Only paths the challenge's patch.targets whitelist covers are accepted; anything else is refused on this call rather than at judge time, so the caller learns which file was rejected without waiting for a build.

Encoding is handled here: the wire carries base64 because a patch target may legitimately be binary.

def submit_for_challenge( self, challenge_id: str, files: dict[str, bytes], *, competition_id: str = '') -> ctfy.server.models.PatchSubmissionInfo:
236    def submit_for_challenge(
237        self,
238        challenge_id: str,
239        files: dict[str, bytes],
240        *,
241        competition_id: str = "",
242    ) -> PatchSubmissionInfo:
243        """Submit a patch with **no box running**.
244
245        :meth:`source` has always worked before an instance exists and
246        after the event ends — that is what its own docstring calls the
247        thing that makes upsolve possible — while :meth:`submit`
248        required an instance, so the round trip it advertises had a
249        broken second half. This is that half: upsolve, virtual
250        participation, and a defender fixing a challenge after their box
251        expired.
252
253        A separate method rather than an optional ``instance_id``: the
254        two are different addressings, not a default, and the split
255        keeps them as obvious as :meth:`submit` versus
256        :meth:`submit_live`.
257
258        The credited team comes from the caller's own membership for
259        ``competition_id`` — there is no team on the wire — so this is
260        no weaker than naming a box you own.
261        """
262        resp = self._http.request(
263            "POST",
264            "/patches",
265            json={
266                "challenge_id": challenge_id,
267                "competition_id": competition_id,
268                "files": {
269                    path: base64.b64encode(content).decode() for path, content in files.items()
270                },
271            },
272        )
273        _raise_for_status(resp)
274        return PatchSubmissionInfo.model_validate(resp.json())

Submit a patch with no box running.

source() has always worked before an instance exists and after the event ends — that is what its own docstring calls the thing that makes upsolve possible — while submit() required an instance, so the round trip it advertises had a broken second half. This is that half: upsolve, virtual participation, and a defender fixing a challenge after their box expired.

A separate method rather than an optional instance_id: the two are different addressings, not a default, and the split keeps them as obvious as submit() versus submit_live().

The credited team comes from the caller's own membership for competition_id — there is no team on the wire — so this is no weaker than naming a box you own.

def submit_live( self, instance_id: str) -> ctfy.server.models.PatchSubmissionInfo:
276    def submit_live(self, instance_id: str) -> PatchSubmissionInfo:
277        """Submit whatever is *currently in the box* — no payload.
278
279        The channel an AWD player actually uses: SSH in, edit in place,
280        then ask the platform to read the result back and judge it. The
281        platform decides which paths to read, from the challenge's
282        pristine source tree — the box never gets to nominate what is
283        graded.
284
285        Requires the challenge to declare a ``patch.live`` block;
286        without one the platform answers ``patch_live_unsupported`` and
287        the upload form is the only channel.
288        """
289        resp = self._http.request(
290            "POST",
291            "/patches",
292            json={"instance_id": instance_id, "source": "live"},
293        )
294        _raise_for_status(resp)
295        return PatchSubmissionInfo.model_validate(resp.json())

Submit whatever is currently in the box — no payload.

The channel an AWD player actually uses: SSH in, edit in place, then ask the platform to read the result back and judge it. The platform decides which paths to read, from the challenge's pristine source tree — the box never gets to nominate what is graded.

Requires the challenge to declare a patch.live block; without one the platform answers patch_live_unsupported and the upload form is the only channel.

def list( self, *, challenge_id: str = '') -> list[ctfy.server.models.PatchSubmissionInfo]:
297    def list(self, *, challenge_id: str = "") -> builtins.list[PatchSubmissionInfo]:
298        """This team's defence history, newest first.
299
300        Carries each submission's status and verdict but not its code —
301        the stored row keeps the bytes so a verdict can be re-derived,
302        while this is a status view that gets polled.
303        """
304        params = {"challenge_id": challenge_id} if challenge_id else {}
305        resp = self._http.request("GET", "/patches", params=params)
306        _raise_for_status(resp)
307        return [PatchSubmissionInfo.model_validate(row) for row in resp.json()]

This team's defence history, newest first.

Carries each submission's status and verdict but not its code — the stored row keeps the bytes so a verdict can be re-derived, while this is a status view that gets polled.