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

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

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