ctfy.sdk.admin_resources.authoring
admin.authored_challenges — the operator's review queue.
The other half of ctfy.sdk.resources.authoring: entrants upload, an
operator rules. Every method here is admin-gated server-side.
⚠️ Reading a file is a first-class verb and not a convenience. An
approval is a person saying they have looked at somebody else's code
before it runs on a node, so a client that could approve without being
able to read would make the verdict a formality. See
ctfy.server.routes.admin_authoring.
1"""``admin.authored_challenges`` — the operator's review queue. 2 3The other half of `ctfy.sdk.resources.authoring`: entrants upload, an 4operator rules. Every method here is admin-gated server-side. 5 6⚠️ **Reading a file is a first-class verb and not a convenience.** An 7approval is a person saying they have looked at somebody else's code 8before it runs on a node, so a client that could approve without being 9able to read would make the verdict a formality. See 10``ctfy.server.routes.admin_authoring``. 11""" 12 13from __future__ import annotations 14 15import builtins 16 17from ctfy.core.enums import AuthoredDestination, AuthoredQueueOrder, AuthoredSmokeFilter 18from ctfy.core.state.models import ReviewStatus 19from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 20from ctfy.sdk.base import BaseHttpClient 21from ctfy.server.models import ( 22 AdminAuthoredChallengeDetail, 23 AdminAuthoredChallengeRow, 24 AdminAuthoredRevisionRow, 25 AdminTaskInfo, 26 AuthoredAuditReport, 27 AuthoredBuildCapability, 28 AuthoredChallengeFileBody, 29 AuthoredGraduationInfo, 30 AuthoredRevisionDiff, 31) 32 33 34class AdminAuthoredChallengesResource: 35 """List, read and rule on player-authored uploads.""" 36 37 def __init__(self, http: BaseHttpClient) -> None: 38 self._http = http 39 40 def list( 41 self, 42 *, 43 competition_id: str | None = None, 44 team_id: str | None = None, 45 review_status: ReviewStatus | None = None, 46 smoke: AuthoredSmokeFilter | None = None, 47 revision: ReviewStatus | None = None, 48 order: AuthoredQueueOrder | None = None, 49 destination: AuthoredDestination | None = None, 50 offset: int = 0, 51 limit: int = 50, 52 ) -> PagedList[AdminAuthoredChallengeRow]: 53 """The queue, oldest first. 54 55 ``None`` on a filter means *every*; ``""`` is a value, so 56 passing it asks for the standalone catalog rather than for 57 everything. 58 59 ``smoke`` narrows by what the smoke run found: a verdict, 60 ``not_run`` for none yet, or ``in_flight`` for a run queued or 61 running (each row's ``smoke_in_flight`` says which). The total is 62 counted over the same filter. 63 64 ``revision`` narrows to uploads whose proposed revision carries 65 that verdict (plan P3.1): ``pending`` waits for a reviewer, 66 ``approved`` for :meth:`apply_revision`. 67 68 ``order="reputation"`` (plan P3.5) puts first the uploads of 69 authors whose published challenges players received best — each 70 row's ``author_reputation`` — then every upload with no reading, 71 oldest first; ``None`` leaves the server's order, oldest first. It 72 is ordered before the page is cut, so ``offset`` pages through 73 that order. 74 75 ``destination`` narrows to where the author sent the upload (plan 76 P3.4): ``public`` or ``eval_only``, the evaluation pool. 77 """ 78 params: dict[str, object] = {"offset": offset, "limit": limit} 79 if competition_id is not None: 80 params["competition_id"] = competition_id 81 if team_id is not None: 82 params["team_id"] = team_id 83 if review_status is not None: 84 params["review_status"] = review_status 85 if smoke is not None: 86 params["smoke"] = smoke 87 if revision is not None: 88 params["revision"] = revision 89 if order is not None: 90 params["order"] = order 91 if destination is not None: 92 params["destination"] = destination 93 resp = self._http.request("GET", "/admin/authored-challenges", params=params) 94 _raise_for_status(resp) 95 return _extract_items(resp.json(), AdminAuthoredChallengeRow) 96 97 def build_capability(self) -> AuthoredBuildCapability: 98 """Whether an approval on this deployment can become a live challenge. 99 100 ⚠️ **Ask this before reading a verdict as a deployment.** An 101 ``approved`` row is a person saying they read the code; it is not 102 a challenge in front of anybody until this answers ``can_build``. 103 104 ⚠️ **This line used to end "and today it never does".** That was 105 true until `authored_sync` landed and is not any more: with a 106 node marked ``untrusted_builds`` the answer is now ``True``, and 107 a caller that still reads it as a constant would skip the one 108 check that distinguishes a deployment which can run an approved 109 upload from one which cannot. 110 111 ⚠️ **It answers for the deployment, never for a tree.** Whether 112 *this* challenge reached a node is `push`'s task and its 113 per-node lines. 114 """ 115 resp = self._http.request("GET", "/admin/authoring/build-capability") 116 _raise_for_status(resp) 117 return AuthoredBuildCapability.model_validate(resp.json()) 118 119 def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail: 120 """One upload, with the paths and sizes of what is in it.""" 121 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}") 122 _raise_for_status(resp) 123 return AdminAuthoredChallengeDetail.model_validate(resp.json()) 124 125 def audit(self, challenge_id: str) -> AuthoredAuditReport: 126 """Run the challenge audit over this upload and report it. 127 128 ⚠️ **Evidence, never a gate.** :meth:`review` does not consult 129 this: a rule that refused an approval on a finding would make 130 the operator's judgement the audit's to overrule, and the audit 131 cannot see inside a `Dockerfile`'s `RUN` — which is the half a 132 person is reading the files for. 133 134 ⚠️ Read ``outcome`` before the counts. An empty ``findings`` 135 list means *nothing was found* only when it says ``ok``; 136 ``timed_out`` and ``failed`` are the audit declining to answer, 137 and reading either as clean is the whole reason the field 138 exists. 139 """ 140 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit") 141 _raise_for_status(resp) 142 return AuthoredAuditReport.model_validate(resp.json()) 143 144 def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody: 145 """One file's contents. 146 147 ⚠️ The body is **attacker-authored text written to be run**. 148 ``binary`` and ``truncated`` say what you are not looking at; 149 treat a ``False`` on either as the only claim that you are. 150 """ 151 # ⚠️ The path rides the body, never the URL (#1929): a WAF that 152 # refuses a URL ending in ``.ini`` does not read a JSON body. 153 resp = self._http.request( 154 "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path} 155 ) 156 _raise_for_status(resp) 157 return AuthoredChallengeFileBody.model_validate(resp.json()) 158 159 def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo: 160 """Whether an upload may graduate into the corpus, and as what (plan P3.3). 161 162 ``refusals`` names every reason it may not yet — empty when it may. 163 ``corpus_id`` is the id it would take: the one passed, or else the 164 next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The 165 pull request's title and body are what a person opens it with. 166 """ 167 params = {"corpus_id": corpus_id} if corpus_id else None 168 resp = self._http.request( 169 "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params 170 ) 171 _raise_for_status(resp) 172 return AuthoredGraduationInfo.model_validate(resp.json()) 173 174 def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes: 175 """The ``.tar.gz`` to unpack at the corpus repository's root. 176 177 It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the 178 ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is 179 recorded on the platform — opening the pull request is a person's 180 act, in another repository. 181 """ 182 resp = self._http.request( 183 "GET", 184 f"/admin/authored-challenges/{challenge_id}/graduation/bundle", 185 params={"corpus_id": corpus_id}, 186 ) 187 _raise_for_status(resp) 188 return resp.content 189 190 def review( 191 self, challenge_id: str, *, status: ReviewStatus, note: str = "" 192 ) -> AdminAuthoredChallengeRow: 193 """Record a verdict. ``rejected`` requires a note. 194 195 ``pending`` withdraws a verdict and clears its trail — a 196 mis-clicked approval is undoable without deleting the upload. 197 """ 198 resp = self._http.request( 199 "POST", 200 f"/admin/authored-challenges/{challenge_id}/review", 201 json={"status": status, "note": note}, 202 ) 203 _raise_for_status(resp) 204 return AdminAuthoredChallengeRow.model_validate(resp.json()) 205 206 def push(self, challenge_id: str) -> AdminTaskInfo: 207 """Send an approved upload to every node marked for untrusted builds. 208 209 ⚠️ **The verb after `publish`, and they are not the same act.** 210 `publish` writes the tree into *this deployment's* catalog root; 211 a multi-host node's catalog is a different directory on a 212 different machine, and until this runs the node answers 404 for 213 the id the platform now lists. 214 215 Returns the task: the push is per node, and which node refused 216 is on its log rather than in one summary line. 217 """ 218 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push") 219 _raise_for_status(resp) 220 return AdminTaskInfo.model_validate(resp.json()) 221 222 def smoke(self, challenge_id: str) -> AdminTaskInfo: 223 """Run this upload's own reference exploit against it, on a node. 224 225 ⚠️ **The machine check an approval cannot make.** The audit is 226 static and a reviewer reads files; only a run says whether the 227 estate comes up and the exploit captures what it claims. 228 229 Returns the task — the verdict is on its log, and it is 230 deliberately four-valued: *solved*, *unsolved*, *estate_failed*, 231 *malformed*, plus *errored* for a node that could not tell. 232 """ 233 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/smoke") 234 _raise_for_status(resp) 235 return AdminTaskInfo.model_validate(resp.json()) 236 237 def publish( 238 self, challenge_id: str, *, exec_surface_reviewed: bool = False 239 ) -> AdminAuthoredChallengeRow: 240 """Write an approved upload into the deployment's catalog. 241 242 A separate verb from :meth:`review` on purpose: approving is a 243 person's verdict on somebody else's code, and it must not fail 244 because a corpus commit landed on the id in the days since. The 245 409 this raises carries every reason at once — the id is taken, 246 the row is not approved, the tree has no ``metadata.yaml`` — so a 247 caller fixes them in one pass rather than one press at a time. 248 249 ``exec_surface_reviewed`` confirms you read the files the tree 250 runs on a node or hands players to run (``executes`` on 251 :meth:`get`'s file list, plan P2.3). A tree with such files is 252 refused without it — once; the stamp then records who read it. 253 254 The row that comes back is **read back from the store**, so its 255 ``in_catalog`` is a fact about the disk after the write. 256 """ 257 resp = self._http.request( 258 "POST", 259 f"/admin/authored-challenges/{challenge_id}/publish", 260 # Only what was said: absent is the server's own default. 261 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 262 ) 263 _raise_for_status(resp) 264 return AdminAuthoredChallengeRow.model_validate(resp.json()) 265 266 def unpublish(self, challenge_id: str) -> AdminAuthoredChallengeRow: 267 """Take a published challenge back out of the deployment's catalog. 268 269 :meth:`publish` had no inverse, so an upload that turned out to 270 leak its own answer stayed in front of every entrant and the only 271 recourse was a shell on the platform host. 272 273 It deletes the *publication*, never the row: the author's tree, 274 the reviewer's verdict and the smoke result all survive, and 275 publishing again is the same call it always was. Entrants already 276 holding a box keep it until its own TTL — what closes is the 277 door, so nobody new can launch. 278 279 Raises 409 when the deployment's catalog does not hold the id, 280 which is the mirror of publishing something already published. 281 """ 282 resp = self._http.request("DELETE", f"/admin/authored-challenges/{challenge_id}/publish") 283 _raise_for_status(resp) 284 return AdminAuthoredChallengeRow.model_validate(resp.json()) 285 286 def curate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow: 287 """Put a published upload in front of this competition's entrants. 288 289 ⚠️ **The step after `publish`, and the two are not one act.** 290 Publishing writes the tree into this deployment's catalog; 291 curating adds its id to *one competition's* challenge set. Until 292 this existed the only way to take it was the competition edit 293 form, which re-sends the whole list — so two organisers working a 294 review queue dropped each other's additions. This appends. 295 296 Refused (409, with reasons) for a competition whose set comes 297 from a catalog bucket, and for an id this deployment has not 298 published: the second would put a challenge in front of entrants 299 that no node can start. 300 301 ``competition_id`` names another competition to curate it into — 302 the workshop → arena step — and every refusal is then asked of 303 that one. Empty is the upload's own competition. 304 """ 305 resp = self._http.request( 306 "POST", 307 f"/admin/authored-challenges/{challenge_id}/curation", 308 params={"competition_id": competition_id} if competition_id else None, 309 ) 310 _raise_for_status(resp) 311 return AdminAuthoredChallengeRow.model_validate(resp.json()) 312 313 def uncurate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow: 314 """Take an upload back out of this competition's challenge set. 315 316 Carries neither of :meth:`curate`'s refusals: removing an id from 317 a set that should not hold it is the repair, so gating it on the 318 same conditions would keep the broken state. ``competition_id`` 319 names which competition; empty is the upload's own. 320 """ 321 resp = self._http.request( 322 "DELETE", 323 f"/admin/authored-challenges/{challenge_id}/curation", 324 params={"competition_id": competition_id} if competition_id else None, 325 ) 326 _raise_for_status(resp) 327 return AdminAuthoredChallengeRow.model_validate(resp.json()) 328 329 def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]: 330 """Every revision of one upload, newest first — the proposal, if 331 any, and the history of those applied (plan P3.1).""" 332 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions") 333 _raise_for_status(resp) 334 return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()] 335 336 def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff: 337 """A proposed revision against the live tree — what a reviewer reads. 338 339 ⚠️ A changed file with an empty ``diff`` says why in ``omitted`` 340 (``binary``, ``too_large``, ``over_budget``); read such a file 341 whole with :meth:`read_revision_file`. ``needs_exec_review`` says 342 whether :meth:`apply_revision` will ask you to confirm the 343 execution-surface review. 344 """ 345 resp = self._http.request( 346 "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff" 347 ) 348 _raise_for_status(resp) 349 return AuthoredRevisionDiff.model_validate(resp.json()) 350 351 def read_revision_file( 352 self, challenge_id: str, revision_id: str, path: str 353 ) -> AuthoredChallengeFileBody: 354 """One file of a revision, whole — attacker-authored text written 355 to be run, like :meth:`read_file`'s.""" 356 resp = self._http.request( 357 "POST", 358 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file", 359 json={"path": path}, 360 ) 361 _raise_for_status(resp) 362 return AuthoredChallengeFileBody.model_validate(resp.json()) 363 364 def review_revision( 365 self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = "" 366 ) -> AdminAuthoredRevisionRow: 367 """Record a verdict on a proposed revision. ``rejected`` requires a 368 note and ``pending`` withdraws a verdict, as for an upload. An 369 applied revision is history and is refused (409).""" 370 resp = self._http.request( 371 "POST", 372 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review", 373 json={"status": status, "note": note}, 374 ) 375 _raise_for_status(resp) 376 return AdminAuthoredRevisionRow.model_validate(resp.json()) 377 378 def apply_revision( 379 self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False 380 ) -> AdminAuthoredChallengeRow: 381 """Make an approved revision the upload's live version (plan P3.1). 382 383 A published upload's directory is replaced whole — boxes already 384 running keep the bytes they started with — and the catalog is 385 reloaded. Nodes that hold the upload keep the previous tree until 386 :meth:`push` sends the new one. 387 388 ``exec_surface_reviewed`` confirms you read the revision's files 389 marked ``executes``: a published upload whose revision has any is 390 refused without it, every time — the bytes are new. The 409 carries 391 every reason at once. 392 """ 393 resp = self._http.request( 394 "POST", 395 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply", 396 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 397 ) 398 _raise_for_status(resp) 399 return AdminAuthoredChallengeRow.model_validate(resp.json())
35class AdminAuthoredChallengesResource: 36 """List, read and rule on player-authored uploads.""" 37 38 def __init__(self, http: BaseHttpClient) -> None: 39 self._http = http 40 41 def list( 42 self, 43 *, 44 competition_id: str | None = None, 45 team_id: str | None = None, 46 review_status: ReviewStatus | None = None, 47 smoke: AuthoredSmokeFilter | None = None, 48 revision: ReviewStatus | None = None, 49 order: AuthoredQueueOrder | None = None, 50 destination: AuthoredDestination | None = None, 51 offset: int = 0, 52 limit: int = 50, 53 ) -> PagedList[AdminAuthoredChallengeRow]: 54 """The queue, oldest first. 55 56 ``None`` on a filter means *every*; ``""`` is a value, so 57 passing it asks for the standalone catalog rather than for 58 everything. 59 60 ``smoke`` narrows by what the smoke run found: a verdict, 61 ``not_run`` for none yet, or ``in_flight`` for a run queued or 62 running (each row's ``smoke_in_flight`` says which). The total is 63 counted over the same filter. 64 65 ``revision`` narrows to uploads whose proposed revision carries 66 that verdict (plan P3.1): ``pending`` waits for a reviewer, 67 ``approved`` for :meth:`apply_revision`. 68 69 ``order="reputation"`` (plan P3.5) puts first the uploads of 70 authors whose published challenges players received best — each 71 row's ``author_reputation`` — then every upload with no reading, 72 oldest first; ``None`` leaves the server's order, oldest first. It 73 is ordered before the page is cut, so ``offset`` pages through 74 that order. 75 76 ``destination`` narrows to where the author sent the upload (plan 77 P3.4): ``public`` or ``eval_only``, the evaluation pool. 78 """ 79 params: dict[str, object] = {"offset": offset, "limit": limit} 80 if competition_id is not None: 81 params["competition_id"] = competition_id 82 if team_id is not None: 83 params["team_id"] = team_id 84 if review_status is not None: 85 params["review_status"] = review_status 86 if smoke is not None: 87 params["smoke"] = smoke 88 if revision is not None: 89 params["revision"] = revision 90 if order is not None: 91 params["order"] = order 92 if destination is not None: 93 params["destination"] = destination 94 resp = self._http.request("GET", "/admin/authored-challenges", params=params) 95 _raise_for_status(resp) 96 return _extract_items(resp.json(), AdminAuthoredChallengeRow) 97 98 def build_capability(self) -> AuthoredBuildCapability: 99 """Whether an approval on this deployment can become a live challenge. 100 101 ⚠️ **Ask this before reading a verdict as a deployment.** An 102 ``approved`` row is a person saying they read the code; it is not 103 a challenge in front of anybody until this answers ``can_build``. 104 105 ⚠️ **This line used to end "and today it never does".** That was 106 true until `authored_sync` landed and is not any more: with a 107 node marked ``untrusted_builds`` the answer is now ``True``, and 108 a caller that still reads it as a constant would skip the one 109 check that distinguishes a deployment which can run an approved 110 upload from one which cannot. 111 112 ⚠️ **It answers for the deployment, never for a tree.** Whether 113 *this* challenge reached a node is `push`'s task and its 114 per-node lines. 115 """ 116 resp = self._http.request("GET", "/admin/authoring/build-capability") 117 _raise_for_status(resp) 118 return AuthoredBuildCapability.model_validate(resp.json()) 119 120 def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail: 121 """One upload, with the paths and sizes of what is in it.""" 122 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}") 123 _raise_for_status(resp) 124 return AdminAuthoredChallengeDetail.model_validate(resp.json()) 125 126 def audit(self, challenge_id: str) -> AuthoredAuditReport: 127 """Run the challenge audit over this upload and report it. 128 129 ⚠️ **Evidence, never a gate.** :meth:`review` does not consult 130 this: a rule that refused an approval on a finding would make 131 the operator's judgement the audit's to overrule, and the audit 132 cannot see inside a `Dockerfile`'s `RUN` — which is the half a 133 person is reading the files for. 134 135 ⚠️ Read ``outcome`` before the counts. An empty ``findings`` 136 list means *nothing was found* only when it says ``ok``; 137 ``timed_out`` and ``failed`` are the audit declining to answer, 138 and reading either as clean is the whole reason the field 139 exists. 140 """ 141 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit") 142 _raise_for_status(resp) 143 return AuthoredAuditReport.model_validate(resp.json()) 144 145 def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody: 146 """One file's contents. 147 148 ⚠️ The body is **attacker-authored text written to be run**. 149 ``binary`` and ``truncated`` say what you are not looking at; 150 treat a ``False`` on either as the only claim that you are. 151 """ 152 # ⚠️ The path rides the body, never the URL (#1929): a WAF that 153 # refuses a URL ending in ``.ini`` does not read a JSON body. 154 resp = self._http.request( 155 "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path} 156 ) 157 _raise_for_status(resp) 158 return AuthoredChallengeFileBody.model_validate(resp.json()) 159 160 def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo: 161 """Whether an upload may graduate into the corpus, and as what (plan P3.3). 162 163 ``refusals`` names every reason it may not yet — empty when it may. 164 ``corpus_id`` is the id it would take: the one passed, or else the 165 next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The 166 pull request's title and body are what a person opens it with. 167 """ 168 params = {"corpus_id": corpus_id} if corpus_id else None 169 resp = self._http.request( 170 "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params 171 ) 172 _raise_for_status(resp) 173 return AuthoredGraduationInfo.model_validate(resp.json()) 174 175 def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes: 176 """The ``.tar.gz`` to unpack at the corpus repository's root. 177 178 It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the 179 ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is 180 recorded on the platform — opening the pull request is a person's 181 act, in another repository. 182 """ 183 resp = self._http.request( 184 "GET", 185 f"/admin/authored-challenges/{challenge_id}/graduation/bundle", 186 params={"corpus_id": corpus_id}, 187 ) 188 _raise_for_status(resp) 189 return resp.content 190 191 def review( 192 self, challenge_id: str, *, status: ReviewStatus, note: str = "" 193 ) -> AdminAuthoredChallengeRow: 194 """Record a verdict. ``rejected`` requires a note. 195 196 ``pending`` withdraws a verdict and clears its trail — a 197 mis-clicked approval is undoable without deleting the upload. 198 """ 199 resp = self._http.request( 200 "POST", 201 f"/admin/authored-challenges/{challenge_id}/review", 202 json={"status": status, "note": note}, 203 ) 204 _raise_for_status(resp) 205 return AdminAuthoredChallengeRow.model_validate(resp.json()) 206 207 def push(self, challenge_id: str) -> AdminTaskInfo: 208 """Send an approved upload to every node marked for untrusted builds. 209 210 ⚠️ **The verb after `publish`, and they are not the same act.** 211 `publish` writes the tree into *this deployment's* catalog root; 212 a multi-host node's catalog is a different directory on a 213 different machine, and until this runs the node answers 404 for 214 the id the platform now lists. 215 216 Returns the task: the push is per node, and which node refused 217 is on its log rather than in one summary line. 218 """ 219 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push") 220 _raise_for_status(resp) 221 return AdminTaskInfo.model_validate(resp.json()) 222 223 def smoke(self, challenge_id: str) -> AdminTaskInfo: 224 """Run this upload's own reference exploit against it, on a node. 225 226 ⚠️ **The machine check an approval cannot make.** The audit is 227 static and a reviewer reads files; only a run says whether the 228 estate comes up and the exploit captures what it claims. 229 230 Returns the task — the verdict is on its log, and it is 231 deliberately four-valued: *solved*, *unsolved*, *estate_failed*, 232 *malformed*, plus *errored* for a node that could not tell. 233 """ 234 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/smoke") 235 _raise_for_status(resp) 236 return AdminTaskInfo.model_validate(resp.json()) 237 238 def publish( 239 self, challenge_id: str, *, exec_surface_reviewed: bool = False 240 ) -> AdminAuthoredChallengeRow: 241 """Write an approved upload into the deployment's catalog. 242 243 A separate verb from :meth:`review` on purpose: approving is a 244 person's verdict on somebody else's code, and it must not fail 245 because a corpus commit landed on the id in the days since. The 246 409 this raises carries every reason at once — the id is taken, 247 the row is not approved, the tree has no ``metadata.yaml`` — so a 248 caller fixes them in one pass rather than one press at a time. 249 250 ``exec_surface_reviewed`` confirms you read the files the tree 251 runs on a node or hands players to run (``executes`` on 252 :meth:`get`'s file list, plan P2.3). A tree with such files is 253 refused without it — once; the stamp then records who read it. 254 255 The row that comes back is **read back from the store**, so its 256 ``in_catalog`` is a fact about the disk after the write. 257 """ 258 resp = self._http.request( 259 "POST", 260 f"/admin/authored-challenges/{challenge_id}/publish", 261 # Only what was said: absent is the server's own default. 262 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 263 ) 264 _raise_for_status(resp) 265 return AdminAuthoredChallengeRow.model_validate(resp.json()) 266 267 def unpublish(self, challenge_id: str) -> AdminAuthoredChallengeRow: 268 """Take a published challenge back out of the deployment's catalog. 269 270 :meth:`publish` had no inverse, so an upload that turned out to 271 leak its own answer stayed in front of every entrant and the only 272 recourse was a shell on the platform host. 273 274 It deletes the *publication*, never the row: the author's tree, 275 the reviewer's verdict and the smoke result all survive, and 276 publishing again is the same call it always was. Entrants already 277 holding a box keep it until its own TTL — what closes is the 278 door, so nobody new can launch. 279 280 Raises 409 when the deployment's catalog does not hold the id, 281 which is the mirror of publishing something already published. 282 """ 283 resp = self._http.request("DELETE", f"/admin/authored-challenges/{challenge_id}/publish") 284 _raise_for_status(resp) 285 return AdminAuthoredChallengeRow.model_validate(resp.json()) 286 287 def curate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow: 288 """Put a published upload in front of this competition's entrants. 289 290 ⚠️ **The step after `publish`, and the two are not one act.** 291 Publishing writes the tree into this deployment's catalog; 292 curating adds its id to *one competition's* challenge set. Until 293 this existed the only way to take it was the competition edit 294 form, which re-sends the whole list — so two organisers working a 295 review queue dropped each other's additions. This appends. 296 297 Refused (409, with reasons) for a competition whose set comes 298 from a catalog bucket, and for an id this deployment has not 299 published: the second would put a challenge in front of entrants 300 that no node can start. 301 302 ``competition_id`` names another competition to curate it into — 303 the workshop → arena step — and every refusal is then asked of 304 that one. Empty is the upload's own competition. 305 """ 306 resp = self._http.request( 307 "POST", 308 f"/admin/authored-challenges/{challenge_id}/curation", 309 params={"competition_id": competition_id} if competition_id else None, 310 ) 311 _raise_for_status(resp) 312 return AdminAuthoredChallengeRow.model_validate(resp.json()) 313 314 def uncurate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow: 315 """Take an upload back out of this competition's challenge set. 316 317 Carries neither of :meth:`curate`'s refusals: removing an id from 318 a set that should not hold it is the repair, so gating it on the 319 same conditions would keep the broken state. ``competition_id`` 320 names which competition; empty is the upload's own. 321 """ 322 resp = self._http.request( 323 "DELETE", 324 f"/admin/authored-challenges/{challenge_id}/curation", 325 params={"competition_id": competition_id} if competition_id else None, 326 ) 327 _raise_for_status(resp) 328 return AdminAuthoredChallengeRow.model_validate(resp.json()) 329 330 def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]: 331 """Every revision of one upload, newest first — the proposal, if 332 any, and the history of those applied (plan P3.1).""" 333 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions") 334 _raise_for_status(resp) 335 return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()] 336 337 def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff: 338 """A proposed revision against the live tree — what a reviewer reads. 339 340 ⚠️ A changed file with an empty ``diff`` says why in ``omitted`` 341 (``binary``, ``too_large``, ``over_budget``); read such a file 342 whole with :meth:`read_revision_file`. ``needs_exec_review`` says 343 whether :meth:`apply_revision` will ask you to confirm the 344 execution-surface review. 345 """ 346 resp = self._http.request( 347 "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff" 348 ) 349 _raise_for_status(resp) 350 return AuthoredRevisionDiff.model_validate(resp.json()) 351 352 def read_revision_file( 353 self, challenge_id: str, revision_id: str, path: str 354 ) -> AuthoredChallengeFileBody: 355 """One file of a revision, whole — attacker-authored text written 356 to be run, like :meth:`read_file`'s.""" 357 resp = self._http.request( 358 "POST", 359 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file", 360 json={"path": path}, 361 ) 362 _raise_for_status(resp) 363 return AuthoredChallengeFileBody.model_validate(resp.json()) 364 365 def review_revision( 366 self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = "" 367 ) -> AdminAuthoredRevisionRow: 368 """Record a verdict on a proposed revision. ``rejected`` requires a 369 note and ``pending`` withdraws a verdict, as for an upload. An 370 applied revision is history and is refused (409).""" 371 resp = self._http.request( 372 "POST", 373 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review", 374 json={"status": status, "note": note}, 375 ) 376 _raise_for_status(resp) 377 return AdminAuthoredRevisionRow.model_validate(resp.json()) 378 379 def apply_revision( 380 self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False 381 ) -> AdminAuthoredChallengeRow: 382 """Make an approved revision the upload's live version (plan P3.1). 383 384 A published upload's directory is replaced whole — boxes already 385 running keep the bytes they started with — and the catalog is 386 reloaded. Nodes that hold the upload keep the previous tree until 387 :meth:`push` sends the new one. 388 389 ``exec_surface_reviewed`` confirms you read the revision's files 390 marked ``executes``: a published upload whose revision has any is 391 refused without it, every time — the bytes are new. The 409 carries 392 every reason at once. 393 """ 394 resp = self._http.request( 395 "POST", 396 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply", 397 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 398 ) 399 _raise_for_status(resp) 400 return AdminAuthoredChallengeRow.model_validate(resp.json())
List, read and rule on player-authored uploads.
41 def list( 42 self, 43 *, 44 competition_id: str | None = None, 45 team_id: str | None = None, 46 review_status: ReviewStatus | None = None, 47 smoke: AuthoredSmokeFilter | None = None, 48 revision: ReviewStatus | None = None, 49 order: AuthoredQueueOrder | None = None, 50 destination: AuthoredDestination | None = None, 51 offset: int = 0, 52 limit: int = 50, 53 ) -> PagedList[AdminAuthoredChallengeRow]: 54 """The queue, oldest first. 55 56 ``None`` on a filter means *every*; ``""`` is a value, so 57 passing it asks for the standalone catalog rather than for 58 everything. 59 60 ``smoke`` narrows by what the smoke run found: a verdict, 61 ``not_run`` for none yet, or ``in_flight`` for a run queued or 62 running (each row's ``smoke_in_flight`` says which). The total is 63 counted over the same filter. 64 65 ``revision`` narrows to uploads whose proposed revision carries 66 that verdict (plan P3.1): ``pending`` waits for a reviewer, 67 ``approved`` for :meth:`apply_revision`. 68 69 ``order="reputation"`` (plan P3.5) puts first the uploads of 70 authors whose published challenges players received best — each 71 row's ``author_reputation`` — then every upload with no reading, 72 oldest first; ``None`` leaves the server's order, oldest first. It 73 is ordered before the page is cut, so ``offset`` pages through 74 that order. 75 76 ``destination`` narrows to where the author sent the upload (plan 77 P3.4): ``public`` or ``eval_only``, the evaluation pool. 78 """ 79 params: dict[str, object] = {"offset": offset, "limit": limit} 80 if competition_id is not None: 81 params["competition_id"] = competition_id 82 if team_id is not None: 83 params["team_id"] = team_id 84 if review_status is not None: 85 params["review_status"] = review_status 86 if smoke is not None: 87 params["smoke"] = smoke 88 if revision is not None: 89 params["revision"] = revision 90 if order is not None: 91 params["order"] = order 92 if destination is not None: 93 params["destination"] = destination 94 resp = self._http.request("GET", "/admin/authored-challenges", params=params) 95 _raise_for_status(resp) 96 return _extract_items(resp.json(), AdminAuthoredChallengeRow)
The queue, oldest first.
None on a filter means every; "" is a value, so
passing it asks for the standalone catalog rather than for
everything.
smoke narrows by what the smoke run found: a verdict,
not_run for none yet, or in_flight for a run queued or
running (each row's smoke_in_flight says which). The total is
counted over the same filter.
revision narrows to uploads whose proposed revision carries
that verdict (plan P3.1): pending waits for a reviewer,
approved for apply_revision().
order="reputation" (plan P3.5) puts first the uploads of
authors whose published challenges players received best — each
row's author_reputation — then every upload with no reading,
oldest first; None leaves the server's order, oldest first. It
is ordered before the page is cut, so offset pages through
that order.
destination narrows to where the author sent the upload (plan
P3.4): public or eval_only, the evaluation pool.
98 def build_capability(self) -> AuthoredBuildCapability: 99 """Whether an approval on this deployment can become a live challenge. 100 101 ⚠️ **Ask this before reading a verdict as a deployment.** An 102 ``approved`` row is a person saying they read the code; it is not 103 a challenge in front of anybody until this answers ``can_build``. 104 105 ⚠️ **This line used to end "and today it never does".** That was 106 true until `authored_sync` landed and is not any more: with a 107 node marked ``untrusted_builds`` the answer is now ``True``, and 108 a caller that still reads it as a constant would skip the one 109 check that distinguishes a deployment which can run an approved 110 upload from one which cannot. 111 112 ⚠️ **It answers for the deployment, never for a tree.** Whether 113 *this* challenge reached a node is `push`'s task and its 114 per-node lines. 115 """ 116 resp = self._http.request("GET", "/admin/authoring/build-capability") 117 _raise_for_status(resp) 118 return AuthoredBuildCapability.model_validate(resp.json())
Whether an approval on this deployment can become a live challenge.
⚠️ Ask this before reading a verdict as a deployment. An
approved row is a person saying they read the code; it is not
a challenge in front of anybody until this answers can_build.
⚠️ This line used to end "and today it never does". That was
true until authored_sync landed and is not any more: with a
node marked untrusted_builds the answer is now True, and
a caller that still reads it as a constant would skip the one
check that distinguishes a deployment which can run an approved
upload from one which cannot.
⚠️ It answers for the deployment, never for a tree. Whether
this challenge reached a node is push's task and its
per-node lines.
120 def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail: 121 """One upload, with the paths and sizes of what is in it.""" 122 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}") 123 _raise_for_status(resp) 124 return AdminAuthoredChallengeDetail.model_validate(resp.json())
One upload, with the paths and sizes of what is in it.
126 def audit(self, challenge_id: str) -> AuthoredAuditReport: 127 """Run the challenge audit over this upload and report it. 128 129 ⚠️ **Evidence, never a gate.** :meth:`review` does not consult 130 this: a rule that refused an approval on a finding would make 131 the operator's judgement the audit's to overrule, and the audit 132 cannot see inside a `Dockerfile`'s `RUN` — which is the half a 133 person is reading the files for. 134 135 ⚠️ Read ``outcome`` before the counts. An empty ``findings`` 136 list means *nothing was found* only when it says ``ok``; 137 ``timed_out`` and ``failed`` are the audit declining to answer, 138 and reading either as clean is the whole reason the field 139 exists. 140 """ 141 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit") 142 _raise_for_status(resp) 143 return AuthoredAuditReport.model_validate(resp.json())
Run the challenge audit over this upload and report it.
⚠️ Evidence, never a gate. review() does not consult
this: a rule that refused an approval on a finding would make
the operator's judgement the audit's to overrule, and the audit
cannot see inside a Dockerfile's RUN — which is the half a
person is reading the files for.
⚠️ Read outcome before the counts. An empty findings
list means nothing was found only when it says ok;
timed_out and failed are the audit declining to answer,
and reading either as clean is the whole reason the field
exists.
145 def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody: 146 """One file's contents. 147 148 ⚠️ The body is **attacker-authored text written to be run**. 149 ``binary`` and ``truncated`` say what you are not looking at; 150 treat a ``False`` on either as the only claim that you are. 151 """ 152 # ⚠️ The path rides the body, never the URL (#1929): a WAF that 153 # refuses a URL ending in ``.ini`` does not read a JSON body. 154 resp = self._http.request( 155 "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path} 156 ) 157 _raise_for_status(resp) 158 return AuthoredChallengeFileBody.model_validate(resp.json())
One file's contents.
⚠️ The body is attacker-authored text written to be run.
binary and truncated say what you are not looking at;
treat a False on either as the only claim that you are.
160 def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo: 161 """Whether an upload may graduate into the corpus, and as what (plan P3.3). 162 163 ``refusals`` names every reason it may not yet — empty when it may. 164 ``corpus_id`` is the id it would take: the one passed, or else the 165 next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The 166 pull request's title and body are what a person opens it with. 167 """ 168 params = {"corpus_id": corpus_id} if corpus_id else None 169 resp = self._http.request( 170 "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params 171 ) 172 _raise_for_status(resp) 173 return AuthoredGraduationInfo.model_validate(resp.json())
Whether an upload may graduate into the corpus, and as what (plan P3.3).
refusals names every reason it may not yet — empty when it may.
corpus_id is the id it would take: the one passed, or else the
next COMMUNITY-NNN this deployment's catalog does not hold. The
pull request's title and body are what a person opens it with.
175 def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes: 176 """The ``.tar.gz`` to unpack at the corpus repository's root. 177 178 It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the 179 ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is 180 recorded on the platform — opening the pull request is a person's 181 act, in another repository. 182 """ 183 resp = self._http.request( 184 "GET", 185 f"/admin/authored-challenges/{challenge_id}/graduation/bundle", 186 params={"corpus_id": corpus_id}, 187 ) 188 _raise_for_status(resp) 189 return resp.content
The .tar.gz to unpack at the corpus repository's root.
It holds COMMUNITY/<corpus_id>/: the tree as reviewed, the
LICENSE its author declared and a CREDITS.md. Nothing is
recorded on the platform — opening the pull request is a person's
act, in another repository.
191 def review( 192 self, challenge_id: str, *, status: ReviewStatus, note: str = "" 193 ) -> AdminAuthoredChallengeRow: 194 """Record a verdict. ``rejected`` requires a note. 195 196 ``pending`` withdraws a verdict and clears its trail — a 197 mis-clicked approval is undoable without deleting the upload. 198 """ 199 resp = self._http.request( 200 "POST", 201 f"/admin/authored-challenges/{challenge_id}/review", 202 json={"status": status, "note": note}, 203 ) 204 _raise_for_status(resp) 205 return AdminAuthoredChallengeRow.model_validate(resp.json())
Record a verdict. rejected requires a note.
pending withdraws a verdict and clears its trail — a
mis-clicked approval is undoable without deleting the upload.
207 def push(self, challenge_id: str) -> AdminTaskInfo: 208 """Send an approved upload to every node marked for untrusted builds. 209 210 ⚠️ **The verb after `publish`, and they are not the same act.** 211 `publish` writes the tree into *this deployment's* catalog root; 212 a multi-host node's catalog is a different directory on a 213 different machine, and until this runs the node answers 404 for 214 the id the platform now lists. 215 216 Returns the task: the push is per node, and which node refused 217 is on its log rather than in one summary line. 218 """ 219 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push") 220 _raise_for_status(resp) 221 return AdminTaskInfo.model_validate(resp.json())
Send an approved upload to every node marked for untrusted builds.
⚠️ The verb after publish, and they are not the same act.
publish writes the tree into this deployment's catalog root;
a multi-host node's catalog is a different directory on a
different machine, and until this runs the node answers 404 for
the id the platform now lists.
Returns the task: the push is per node, and which node refused is on its log rather than in one summary line.
223 def smoke(self, challenge_id: str) -> AdminTaskInfo: 224 """Run this upload's own reference exploit against it, on a node. 225 226 ⚠️ **The machine check an approval cannot make.** The audit is 227 static and a reviewer reads files; only a run says whether the 228 estate comes up and the exploit captures what it claims. 229 230 Returns the task — the verdict is on its log, and it is 231 deliberately four-valued: *solved*, *unsolved*, *estate_failed*, 232 *malformed*, plus *errored* for a node that could not tell. 233 """ 234 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/smoke") 235 _raise_for_status(resp) 236 return AdminTaskInfo.model_validate(resp.json())
Run this upload's own reference exploit against it, on a node.
⚠️ The machine check an approval cannot make. The audit is static and a reviewer reads files; only a run says whether the estate comes up and the exploit captures what it claims.
Returns the task — the verdict is on its log, and it is deliberately four-valued: solved, unsolved, estate_failed, malformed, plus errored for a node that could not tell.
238 def publish( 239 self, challenge_id: str, *, exec_surface_reviewed: bool = False 240 ) -> AdminAuthoredChallengeRow: 241 """Write an approved upload into the deployment's catalog. 242 243 A separate verb from :meth:`review` on purpose: approving is a 244 person's verdict on somebody else's code, and it must not fail 245 because a corpus commit landed on the id in the days since. The 246 409 this raises carries every reason at once — the id is taken, 247 the row is not approved, the tree has no ``metadata.yaml`` — so a 248 caller fixes them in one pass rather than one press at a time. 249 250 ``exec_surface_reviewed`` confirms you read the files the tree 251 runs on a node or hands players to run (``executes`` on 252 :meth:`get`'s file list, plan P2.3). A tree with such files is 253 refused without it — once; the stamp then records who read it. 254 255 The row that comes back is **read back from the store**, so its 256 ``in_catalog`` is a fact about the disk after the write. 257 """ 258 resp = self._http.request( 259 "POST", 260 f"/admin/authored-challenges/{challenge_id}/publish", 261 # Only what was said: absent is the server's own default. 262 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 263 ) 264 _raise_for_status(resp) 265 return AdminAuthoredChallengeRow.model_validate(resp.json())
Write an approved upload into the deployment's catalog.
A separate verb from review() on purpose: approving is a
person's verdict on somebody else's code, and it must not fail
because a corpus commit landed on the id in the days since. The
409 this raises carries every reason at once — the id is taken,
the row is not approved, the tree has no metadata.yaml — so a
caller fixes them in one pass rather than one press at a time.
exec_surface_reviewed confirms you read the files the tree
runs on a node or hands players to run (executes on
get()'s file list, plan P2.3). A tree with such files is
refused without it — once; the stamp then records who read it.
The row that comes back is read back from the store, so its
in_catalog is a fact about the disk after the write.
267 def unpublish(self, challenge_id: str) -> AdminAuthoredChallengeRow: 268 """Take a published challenge back out of the deployment's catalog. 269 270 :meth:`publish` had no inverse, so an upload that turned out to 271 leak its own answer stayed in front of every entrant and the only 272 recourse was a shell on the platform host. 273 274 It deletes the *publication*, never the row: the author's tree, 275 the reviewer's verdict and the smoke result all survive, and 276 publishing again is the same call it always was. Entrants already 277 holding a box keep it until its own TTL — what closes is the 278 door, so nobody new can launch. 279 280 Raises 409 when the deployment's catalog does not hold the id, 281 which is the mirror of publishing something already published. 282 """ 283 resp = self._http.request("DELETE", f"/admin/authored-challenges/{challenge_id}/publish") 284 _raise_for_status(resp) 285 return AdminAuthoredChallengeRow.model_validate(resp.json())
Take a published challenge back out of the deployment's catalog.
publish() had no inverse, so an upload that turned out to
leak its own answer stayed in front of every entrant and the only
recourse was a shell on the platform host.
It deletes the publication, never the row: the author's tree, the reviewer's verdict and the smoke result all survive, and publishing again is the same call it always was. Entrants already holding a box keep it until its own TTL — what closes is the door, so nobody new can launch.
Raises 409 when the deployment's catalog does not hold the id, which is the mirror of publishing something already published.
287 def curate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow: 288 """Put a published upload in front of this competition's entrants. 289 290 ⚠️ **The step after `publish`, and the two are not one act.** 291 Publishing writes the tree into this deployment's catalog; 292 curating adds its id to *one competition's* challenge set. Until 293 this existed the only way to take it was the competition edit 294 form, which re-sends the whole list — so two organisers working a 295 review queue dropped each other's additions. This appends. 296 297 Refused (409, with reasons) for a competition whose set comes 298 from a catalog bucket, and for an id this deployment has not 299 published: the second would put a challenge in front of entrants 300 that no node can start. 301 302 ``competition_id`` names another competition to curate it into — 303 the workshop → arena step — and every refusal is then asked of 304 that one. Empty is the upload's own competition. 305 """ 306 resp = self._http.request( 307 "POST", 308 f"/admin/authored-challenges/{challenge_id}/curation", 309 params={"competition_id": competition_id} if competition_id else None, 310 ) 311 _raise_for_status(resp) 312 return AdminAuthoredChallengeRow.model_validate(resp.json())
Put a published upload in front of this competition's entrants.
⚠️ The step after publish, and the two are not one act.
Publishing writes the tree into this deployment's catalog;
curating adds its id to one competition's challenge set. Until
this existed the only way to take it was the competition edit
form, which re-sends the whole list — so two organisers working a
review queue dropped each other's additions. This appends.
Refused (409, with reasons) for a competition whose set comes from a catalog bucket, and for an id this deployment has not published: the second would put a challenge in front of entrants that no node can start.
competition_id names another competition to curate it into —
the workshop → arena step — and every refusal is then asked of
that one. Empty is the upload's own competition.
314 def uncurate(self, challenge_id: str, competition_id: str = "") -> AdminAuthoredChallengeRow: 315 """Take an upload back out of this competition's challenge set. 316 317 Carries neither of :meth:`curate`'s refusals: removing an id from 318 a set that should not hold it is the repair, so gating it on the 319 same conditions would keep the broken state. ``competition_id`` 320 names which competition; empty is the upload's own. 321 """ 322 resp = self._http.request( 323 "DELETE", 324 f"/admin/authored-challenges/{challenge_id}/curation", 325 params={"competition_id": competition_id} if competition_id else None, 326 ) 327 _raise_for_status(resp) 328 return AdminAuthoredChallengeRow.model_validate(resp.json())
Take an upload back out of this competition's challenge set.
Carries neither of curate()'s refusals: removing an id from
a set that should not hold it is the repair, so gating it on the
same conditions would keep the broken state. competition_id
names which competition; empty is the upload's own.
330 def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]: 331 """Every revision of one upload, newest first — the proposal, if 332 any, and the history of those applied (plan P3.1).""" 333 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions") 334 _raise_for_status(resp) 335 return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()]
Every revision of one upload, newest first — the proposal, if any, and the history of those applied (plan P3.1).
337 def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff: 338 """A proposed revision against the live tree — what a reviewer reads. 339 340 ⚠️ A changed file with an empty ``diff`` says why in ``omitted`` 341 (``binary``, ``too_large``, ``over_budget``); read such a file 342 whole with :meth:`read_revision_file`. ``needs_exec_review`` says 343 whether :meth:`apply_revision` will ask you to confirm the 344 execution-surface review. 345 """ 346 resp = self._http.request( 347 "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff" 348 ) 349 _raise_for_status(resp) 350 return AuthoredRevisionDiff.model_validate(resp.json())
A proposed revision against the live tree — what a reviewer reads.
⚠️ A changed file with an empty diff says why in omitted
(binary, too_large, over_budget); read such a file
whole with read_revision_file(). needs_exec_review says
whether apply_revision() will ask you to confirm the
execution-surface review.
352 def read_revision_file( 353 self, challenge_id: str, revision_id: str, path: str 354 ) -> AuthoredChallengeFileBody: 355 """One file of a revision, whole — attacker-authored text written 356 to be run, like :meth:`read_file`'s.""" 357 resp = self._http.request( 358 "POST", 359 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file", 360 json={"path": path}, 361 ) 362 _raise_for_status(resp) 363 return AuthoredChallengeFileBody.model_validate(resp.json())
One file of a revision, whole — attacker-authored text written
to be run, like read_file()'s.
365 def review_revision( 366 self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = "" 367 ) -> AdminAuthoredRevisionRow: 368 """Record a verdict on a proposed revision. ``rejected`` requires a 369 note and ``pending`` withdraws a verdict, as for an upload. An 370 applied revision is history and is refused (409).""" 371 resp = self._http.request( 372 "POST", 373 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review", 374 json={"status": status, "note": note}, 375 ) 376 _raise_for_status(resp) 377 return AdminAuthoredRevisionRow.model_validate(resp.json())
Record a verdict on a proposed revision. rejected requires a
note and pending withdraws a verdict, as for an upload. An
applied revision is history and is refused (409).
379 def apply_revision( 380 self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False 381 ) -> AdminAuthoredChallengeRow: 382 """Make an approved revision the upload's live version (plan P3.1). 383 384 A published upload's directory is replaced whole — boxes already 385 running keep the bytes they started with — and the catalog is 386 reloaded. Nodes that hold the upload keep the previous tree until 387 :meth:`push` sends the new one. 388 389 ``exec_surface_reviewed`` confirms you read the revision's files 390 marked ``executes``: a published upload whose revision has any is 391 refused without it, every time — the bytes are new. The 409 carries 392 every reason at once. 393 """ 394 resp = self._http.request( 395 "POST", 396 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply", 397 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 398 ) 399 _raise_for_status(resp) 400 return AdminAuthoredChallengeRow.model_validate(resp.json())
Make an approved revision the upload's live version (plan P3.1).
A published upload's directory is replaced whole — boxes already
running keep the bytes they started with — and the catalog is
reloaded. Nodes that hold the upload keep the previous tree until
push() sends the new one.
exec_surface_reviewed confirms you read the revision's files
marked executes: a published upload whose revision has any is
refused without it, every time — the bytes are new. The 409 carries
every reason at once.