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 base64 16import builtins 17import json 18from collections.abc import Mapping 19from typing import Literal 20 21from ctfy.core.enums import ( 22 AuthoredDestination, 23 AuthoredFlowAction, 24 AuthoredQueueOrder, 25 AuthoredScoresChoice, 26 AuthoredSmokeFilter, 27 AuthoredVersionSwitchWhen, 28) 29from ctfy.core.precheck_rerun import RerunScope 30from ctfy.core.resource_budget import BuildNetwork 31from ctfy.core.state.models import ReviewStatus 32from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 33from ctfy.sdk.base import BaseHttpClient 34from ctfy.server.models import ( 35 AdminAuthoredChallengeDetail, 36 AdminAuthoredChallengeRow, 37 AdminAuthoredCurationRow, 38 AdminAuthoredRevisionRow, 39 AdminAuthoredVersionRow, 40 AdminMaliceInfo, 41 AdminMaliceStanding, 42 AdminModelReview, 43 AdminModelReviewInfo, 44 AdminProbeInfo, 45 AdminTaskInfo, 46 AuthoredAuditReport, 47 AuthoredBuildCapability, 48 AuthoredChallengeFileBody, 49 AuthoredCompetitionRow, 50 AuthoredFlow, 51 AuthoredGraduationInfo, 52 AuthoredHistory, 53 AuthoredOperatorRevisionInfo, 54 AuthoredOtherUploads, 55 AuthoredPointsAtStake, 56 AuthoredQueueCounts, 57 AuthoredRevisionDiff, 58 AuthoredSmokeLive, 59 AuthoredSmokeLog, 60 AuthoredSmokeLogSummary, 61 AuthoredSmokeStepLines, 62 AuthoredStrayRow, 63 AuthoredStripResult, 64 AuthoredVersionDiff, 65 AuthoredVersionMessage, 66 AuthoredVersionStorageInfo, 67 AuthoredVersionSwitchInfo, 68 AuthoredVersionSwitchPlan, 69 BulkCurationResponse, 70 PrecheckRerunInfo, 71 PrecheckRunsInfo, 72) 73 74 75class AdminAuthoredChallengesResource: 76 """List, read and rule on player-authored uploads.""" 77 78 def __init__(self, http: BaseHttpClient) -> None: 79 self._http = http 80 81 def list( 82 self, 83 *, 84 competition_id: str | None = None, 85 team_id: str | None = None, 86 review_status: ReviewStatus | None = None, 87 smoke: AuthoredSmokeFilter | None = None, 88 revision: ReviewStatus | None = None, 89 order: AuthoredQueueOrder | None = None, 90 destination: AuthoredDestination | None = None, 91 offset: int = 0, 92 limit: int = 50, 93 ) -> PagedList[AdminAuthoredChallengeRow]: 94 """The queue, oldest first. 95 96 ``None`` on a filter means *every*; ``""`` is a value, so 97 passing it asks for the standalone catalog rather than for 98 everything. 99 100 ``smoke`` narrows by what the smoke run found: a verdict, 101 ``not_run`` for none yet, or ``in_flight`` for a run queued or 102 running (each row's ``smoke_in_flight`` says which). The total is 103 counted over the same filter. 104 105 ``revision`` narrows to uploads whose proposed revision carries 106 that verdict (plan P3.1): ``pending`` waits for a reviewer, 107 ``approved`` for :meth:`apply_revision`. 108 109 ``order="reputation"`` (plan P3.5) puts first the uploads of 110 authors whose published challenges players received best — each 111 row's ``author_reputation`` — then every upload with no reading, 112 oldest first; ``None`` leaves the server's order, oldest first. It 113 is ordered before the page is cut, so ``offset`` pages through 114 that order. 115 116 ``destination`` narrows to where the author sent the upload (plan 117 P3.4): ``public`` or ``eval_only``, the evaluation pool. 118 """ 119 params: dict[str, object] = {"offset": offset, "limit": limit} 120 if competition_id is not None: 121 params["competition_id"] = competition_id 122 if team_id is not None: 123 params["team_id"] = team_id 124 if review_status is not None: 125 params["review_status"] = review_status 126 if smoke is not None: 127 params["smoke"] = smoke 128 if revision is not None: 129 params["revision"] = revision 130 if order is not None: 131 params["order"] = order 132 if destination is not None: 133 params["destination"] = destination 134 resp = self._http.request("GET", "/admin/authored-challenges", params=params) 135 _raise_for_status(resp) 136 return _extract_items(resp.json(), AdminAuthoredChallengeRow) 137 138 def queue_counts(self, *, competition_id: str | None = None) -> AuthoredQueueCounts: 139 """How many uploads each review tab holds and how long the longest 140 has waited — one request, counted server-side (#2037 B1).""" 141 params: dict[str, object] = {} 142 if competition_id is not None: 143 params["competition_id"] = competition_id 144 resp = self._http.request("GET", "/admin/authored-challenges/queue-counts", params=params) 145 _raise_for_status(resp) 146 return AuthoredQueueCounts.model_validate(resp.json()) 147 148 def build_capability(self) -> AuthoredBuildCapability: 149 """Whether an approval on this deployment can become a live challenge. 150 151 ⚠️ **Ask this before reading a verdict as a deployment.** An 152 ``approved`` row is a person saying they read the code; it is not 153 a challenge in front of anybody until this answers ``can_build``. 154 155 ⚠️ **This line used to end "and today it never does".** That was 156 true until `authored_sync` landed and is not any more: with a 157 node marked ``untrusted_builds`` the answer is now ``True``, and 158 a caller that still reads it as a constant would skip the one 159 check that distinguishes a deployment which can run an approved 160 upload from one which cannot. 161 162 ⚠️ **It answers for the deployment, never for a tree.** Whether 163 *this* challenge reached a node is `push`'s task and its 164 per-node lines. 165 """ 166 resp = self._http.request("GET", "/admin/authoring/build-capability") 167 _raise_for_status(resp) 168 return AuthoredBuildCapability.model_validate(resp.json()) 169 170 def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail: 171 """One upload, with the paths and sizes of what is in it.""" 172 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}") 173 _raise_for_status(resp) 174 return AdminAuthoredChallengeDetail.model_validate(resp.json()) 175 176 def flow(self, challenge_id: str) -> AuthoredFlow: 177 """Where this upload is in its lifecycle, what a platform admin can do 178 next, and every transition that got it here — names included (#2147).""" 179 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/flow") 180 _raise_for_status(resp) 181 return AuthoredFlow.model_validate(resp.json()) 182 183 def precheck_runs(self, challenge_id: str) -> PrecheckRunsInfo: 184 """The AI pre-check's managed-eval runs of this upload's pending 185 versions, newest first (#3189).""" 186 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs") 187 _raise_for_status(resp) 188 return PrecheckRunsInfo.model_validate(resp.json()) 189 190 def precheck_run_artifact(self, challenge_id: str, run_id: str) -> bytes: 191 """What one pre-check run's agent left behind, as ``.tar.gz`` bytes 192 (#3260). Agent-written content: read it, never serve it.""" 193 resp = self._http.request( 194 "GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs/{run_id}/artifact" 195 ) 196 _raise_for_status(resp) 197 return bytes(resp.content) 198 199 def history(self, challenge_id: str) -> AuthoredHistory: 200 """Everything that happened to this upload, oldest first, as one 201 stream (#3093): the transition log, every AI pre-check and every 202 injection review. Organisers and reviewers only.""" 203 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/history") 204 _raise_for_status(resp) 205 return AuthoredHistory.model_validate(resp.json()) 206 207 def precheck_reruns(self, competition_id: str) -> PrecheckRerunInfo: 208 """The event's re-check card (#3266): the two scopes' counts and the 209 newest batch. Organisers only.""" 210 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/precheck-reruns") 211 _raise_for_status(resp) 212 return PrecheckRerunInfo.model_validate(resp.json()) 213 214 def start_precheck_reruns( 215 self, competition_id: str, *, scope: RerunScope = "unprechecked" 216 ) -> PrecheckRerunInfo: 217 """Re-run the AI pre-check over the event's approved uploads in 218 *scope* (#3266). Evidence only: nothing it finds unpublishes one.""" 219 resp = self._http.request( 220 "POST", 221 f"/admin/competitions/{competition_id}/precheck-reruns", 222 json={"scope": scope}, 223 ) 224 _raise_for_status(resp) 225 return PrecheckRerunInfo.model_validate(resp.json()) 226 227 def stop_precheck_reruns(self, competition_id: str, rerun_id: str) -> PrecheckRerunInfo: 228 """Stop a running re-check batch; what finished is kept (#3266).""" 229 resp = self._http.request( 230 "POST", f"/admin/competitions/{competition_id}/precheck-reruns/{rerun_id}/stop" 231 ) 232 _raise_for_status(resp) 233 return PrecheckRerunInfo.model_validate(resp.json()) 234 235 def smoke_logs(self, challenge_id: str) -> builtins.list[AuthoredSmokeLogSummary]: 236 """The smoke runs kept for this upload, newest first (#2024) — the 237 same scrubbed steps its author reads. The whole set: retention, not 238 a page, bounds it.""" 239 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs") 240 _raise_for_status(resp) 241 return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()] 242 243 def versions(self, challenge_id: str) -> builtins.list[AdminAuthoredVersionRow]: 244 """Every stored version of this upload, newest first (#2037) — the 245 rows its author reads, plus who ruled on each. The whole set.""" 246 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/versions") 247 _raise_for_status(resp) 248 return [AdminAuthoredVersionRow.model_validate(row) for row in resp.json()] 249 250 def competitions(self, challenge_id: str) -> builtins.list[AuthoredCompetitionRow]: 251 """Every competition an upload is curated into, with what players did 252 there (#2037) — starts and solves, counted over the whole set.""" 253 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/competitions") 254 _raise_for_status(resp) 255 return [AuthoredCompetitionRow.model_validate(row) for row in resp.json()] 256 257 def version_diff( 258 self, challenge_id: str, *, from_version: str, to_version: str 259 ) -> AuthoredVersionDiff: 260 """Two versions of this upload compared (#2037) — the author's diff.""" 261 resp = self._http.request( 262 "GET", 263 f"/admin/authored-challenges/{challenge_id}/versions/diff", 264 params={"from_version": from_version, "to_version": to_version}, 265 ) 266 _raise_for_status(resp) 267 return AuthoredVersionDiff.model_validate(resp.json()) 268 269 def version_archive(self, challenge_id: str, version_id: str) -> bytes: 270 """One version as ``.tar.gz`` bytes, execute bits included (#2037).""" 271 resp = self._http.request( 272 "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/archive" 273 ) 274 _raise_for_status(resp) 275 return bytes(resp.content) 276 277 def version_switches(self, challenge_id: str) -> AuthoredVersionSwitchPlan: 278 """Every switch of this upload's live version, newest first, and the 279 running competitions holding it (#2037).""" 280 resp = self._http.request( 281 "GET", f"/admin/authored-challenges/{challenge_id}/version-switches" 282 ) 283 _raise_for_status(resp) 284 return AuthoredVersionSwitchPlan.model_validate(resp.json()) 285 286 def switch_version( 287 self, 288 challenge_id: str, 289 version_id: str, 290 *, 291 when: AuthoredVersionSwitchWhen = "now", 292 reason: str = "", 293 exec_surface_reviewed: bool = False, 294 force: bool = False, 295 force_reason: str = "", 296 ) -> AuthoredVersionSwitchInfo: 297 """Make a version live (#2037): an approved proposal is applied, an 298 earlier approved version rolled back to (``reason`` required). ``when`` 299 is ``now`` or ``after_competitions`` — applied automatically once no 300 running competition holds the challenge. Refused 301 ``authored_version_switch_refused`` with coded reasons. 302 303 ``force`` / ``force_reason`` push an immediate switch through a 304 competition that locks its challenges' versions while in play 305 (#2041): a platform administrator's act, and only with a reason.""" 306 resp = self._http.request( 307 "POST", 308 f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/switch", 309 json={ 310 "when": when, 311 "reason": reason, 312 "exec_surface_reviewed": exec_surface_reviewed, 313 "force": force, 314 "force_reason": force_reason, 315 }, 316 ) 317 _raise_for_status(resp) 318 return AuthoredVersionSwitchInfo.model_validate(resp.json()) 319 320 def cancel_switch(self, challenge_id: str, switch_id: str) -> AuthoredVersionSwitchInfo: 321 """Cancel a switch still waiting for its competitions to end (#2037).""" 322 resp = self._http.request( 323 "POST", 324 f"/admin/authored-challenges/{challenge_id}/version-switches/{switch_id}/cancel", 325 ) 326 _raise_for_status(resp) 327 return AuthoredVersionSwitchInfo.model_validate(resp.json()) 328 329 def version_messages( 330 self, challenge_id: str, version_id: str 331 ) -> builtins.list[AuthoredVersionMessage]: 332 """One version's thread between its authors and reviewers (#2037).""" 333 resp = self._http.request( 334 "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages" 335 ) 336 _raise_for_status(resp) 337 return [AuthoredVersionMessage.model_validate(row) for row in resp.json()] 338 339 def post_version_message( 340 self, challenge_id: str, version_id: str, body: str, *, as_review_group: bool = True 341 ) -> builtins.list[AuthoredVersionMessage]: 342 """Write to an upload's authors on one version's thread (#2037) — as 343 the review group unless ``as_review_group`` is false. The thread.""" 344 resp = self._http.request( 345 "POST", 346 f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages", 347 json={"body": body, "as_review_group": as_review_group}, 348 ) 349 _raise_for_status(resp) 350 return [AuthoredVersionMessage.model_validate(row) for row in resp.json()] 351 352 def version_storage(self) -> AuthoredVersionStorageInfo: 353 """What the version store holds (#2037): ``stored_bytes`` each 354 distinct file once, ``logical_bytes`` every version stored whole.""" 355 resp = self._http.request("GET", "/admin/authoring/version-storage") 356 _raise_for_status(resp) 357 return AuthoredVersionStorageInfo.model_validate(resp.json()) 358 359 def smoke_log(self, challenge_id: str, log_id: str) -> AuthoredSmokeLog: 360 """One smoke run of this upload, step by step (#2024).""" 361 resp = self._http.request( 362 "GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs/{log_id}" 363 ) 364 _raise_for_status(resp) 365 return AuthoredSmokeLog.model_validate(resp.json()) 366 367 def smoke_live(self, challenge_id: str) -> AuthoredSmokeLive: 368 """The smoke run of this upload that is queued or running now (#2145).""" 369 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-live") 370 _raise_for_status(resp) 371 return AuthoredSmokeLive.model_validate(resp.json()) 372 373 def cancel_smoke(self, challenge_id: str) -> AuthoredSmokeLive: 374 """Take back this upload's smoke run while it still waits (#2145).""" 375 resp = self._http.request( 376 "POST", f"/admin/authored-challenges/{challenge_id}/smoke-live/cancel" 377 ) 378 _raise_for_status(resp) 379 return AuthoredSmokeLive.model_validate(resp.json()) 380 381 def smoke_run_lines( 382 self, 383 challenge_id: str, 384 run_id: str, 385 key: str, 386 *, 387 before: int = 0, 388 after: int = 0, 389 limit: int = 200, 390 ) -> AuthoredSmokeStepLines: 391 """A window of one step's full output (#2145) — the same scrubbed 392 lines its author reads; cursors as ``client.authoring.smoke_run_lines``.""" 393 resp = self._http.request( 394 "GET", 395 f"/admin/authored-challenges/{challenge_id}/smoke-runs/{run_id}/lines", 396 params={"step": key, "before": before, "after": after, "limit": limit}, 397 ) 398 _raise_for_status(resp) 399 return AuthoredSmokeStepLines.model_validate(resp.json()) 400 401 def audit(self, challenge_id: str) -> AuthoredAuditReport: 402 """Run the challenge audit over this upload and report it. 403 404 ⚠️ **Evidence, never a gate.** :meth:`review` does not consult 405 this: a rule that refused an approval on a finding would make 406 the operator's judgement the audit's to overrule, and the audit 407 cannot see inside a `Dockerfile`'s `RUN` — which is the half a 408 person is reading the files for. 409 410 ⚠️ Read ``outcome`` before the counts. An empty ``findings`` 411 list means *nothing was found* only when it says ``ok``; 412 ``timed_out`` and ``failed`` are the audit declining to answer, 413 and reading either as clean is the whole reason the field 414 exists. 415 """ 416 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit") 417 _raise_for_status(resp) 418 return AuthoredAuditReport.model_validate(resp.json()) 419 420 def model_review(self, challenge_id: str) -> AdminModelReviewInfo: 421 """The model's reading of this upload and of its proposed revision 422 (#1965 D): the injection verdict with its full reasoning and the 423 spans it quoted, the advisory findings, and near copies. 424 425 ⚠️ Read ``effective_injection`` to know what the platform acts on — 426 ``injection`` is what the model said, and an override makes it 427 ``clean``. ``unknown`` is the platform's problem (``problem`` says 428 which) and never blocks. 429 """ 430 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/model-review") 431 _raise_for_status(resp) 432 return AdminModelReviewInfo.model_validate(resp.json()) 433 434 def run_model_review(self, challenge_id: str, *, revision_id: str = "") -> AdminTaskInfo: 435 """Ask the model to read this upload, or its proposed revision — 436 advice: the press decides nothing. 409 while one is in flight.""" 437 params = {"revision_id": revision_id} if revision_id else None 438 resp = self._http.request( 439 "POST", f"/admin/authored-challenges/{challenge_id}/model-review", params=params 440 ) 441 _raise_for_status(resp) 442 return AdminTaskInfo.model_validate(resp.json()) 443 444 def assistant_review(self, challenge_id: str) -> AdminProbeInfo: 445 """The reviewer assistant's newest run on this upload's live tree and 446 on its proposed revision (#2830) — advice for the person reviewing, 447 never a verdict. ``enabled`` is whether this deployment names a 448 model at all.""" 449 resp = self._http.request( 450 "GET", f"/admin/authored-challenges/{challenge_id}/assistant-review" 451 ) 452 _raise_for_status(resp) 453 return AdminProbeInfo.model_validate(resp.json()) 454 455 def rerun_assistant_review(self, challenge_id: str) -> AdminTaskInfo: 456 """Smoke this upload again with the AI pre-check, even though this 457 version was probed already (#2830, #3053). A rerun *is* a smoke, so it 458 takes :meth:`smoke`'s door (super-admin); ``409 authoring_probe_off`` 459 while the event chose no model, ``authoring_probe_not_isolated`` with 460 no quarantine or dedicated node, ``authoring_probe_budget_spent`` 461 once the version's cap is spent.""" 462 resp = self._http.request( 463 "POST", f"/admin/authored-challenges/{challenge_id}/assistant-review" 464 ) 465 _raise_for_status(resp) 466 return AdminTaskInfo.model_validate(resp.json()) 467 468 def override_model_review( 469 self, challenge_id: str, review_id: str, *, note: str 470 ) -> AdminModelReview: 471 """Say the text a review flagged is not an injection. ``note`` is 472 required — the audit's *why*. An upload or revision the verdict 473 rejected under automatic review is decided again.""" 474 resp = self._http.request( 475 "POST", 476 f"/admin/authored-challenges/{challenge_id}/model-review/{review_id}/override", 477 json={"note": note}, 478 ) 479 _raise_for_status(resp) 480 return AdminModelReview.model_validate(resp.json()) 481 482 def malice_review(self, challenge_id: str) -> AdminMaliceInfo: 483 """The malice check of this upload's live tree and of its proposed 484 revision (#3211): ``hold``, every reason, the rule hits, the model's 485 reading and quotes, and any ruling. Never calls a model.""" 486 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/malice-review") 487 _raise_for_status(resp) 488 return AdminMaliceInfo.model_validate(resp.json()) 489 490 def override_malice_review( 491 self, 492 challenge_id: str, 493 review_id: str, 494 *, 495 decision: Literal["clear", "confirm"], 496 note: str, 497 ) -> AdminMaliceStanding: 498 """Rule on a malice hold: ``clear`` releases it (a version the 499 automatic chain held is decided again), ``confirm`` keeps it. 500 ``note`` is required — the audit's *why*.""" 501 resp = self._http.request( 502 "POST", 503 f"/admin/authored-challenges/{challenge_id}/malice-review/{review_id}/override", 504 json={"decision": decision, "note": note}, 505 ) 506 _raise_for_status(resp) 507 return AdminMaliceStanding.model_validate(resp.json()) 508 509 def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody: 510 """One file's contents. 511 512 ⚠️ The body is **attacker-authored text written to be run**. 513 ``binary`` and ``truncated`` say what you are not looking at; 514 treat a ``False`` on either as the only claim that you are. 515 """ 516 # ⚠️ The path rides the body, never the URL (#1929): a WAF that 517 # refuses a URL ending in ``.ini`` does not read a JSON body. 518 resp = self._http.request( 519 "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path} 520 ) 521 _raise_for_status(resp) 522 return AuthoredChallengeFileBody.model_validate(resp.json()) 523 524 def operator_revise( 525 self, 526 challenge_id: str, 527 *, 528 archive: bytes | None = None, 529 put: Mapping[str, bytes] | None = None, 530 delete: builtins.list[str] | None = None, 531 note: str = "", 532 approve: bool = False, 533 replace_waiting: bool = False, 534 ) -> AuthoredOperatorRevisionInfo: 535 """Submit a new version of somebody else's upload on their behalf (#3002). 536 537 Exactly one way in: ``archive`` (a whole ``.tar.gz``), or edits on top 538 of the current tree — ``put`` maps a path to its new bytes, ``delete`` 539 names paths to remove. The tree meets every rule an upload's does; 540 the author stays the author, the version records you as 541 ``revised_by``, and the uploader is told which files changed. 542 ``approve`` approves it in the same press, under your name. 543 ``replace_waiting`` confirms replacing a revision the author already 544 has waiting (otherwise ``409 authored_operator_revision_replaces_proposal``). 545 """ 546 data: dict[str, str] = { 547 "note": note, 548 "approve": "true" if approve else "", 549 "replace_waiting": "true" if replace_waiting else "", 550 } 551 files: dict[str, tuple[str, bytes, str]] = {} 552 if archive is not None: 553 files["file"] = ("revision.tar.gz", archive, "application/gzip") 554 else: 555 edits = [ 556 {"path": path, "content_b64": base64.b64encode(body).decode()} 557 for path, body in (put or {}).items() 558 ] + [{"path": path, "content_b64": None} for path in (delete or [])] 559 data["edits"] = json.dumps(edits) 560 resp = self._http.request( 561 "POST", 562 f"/admin/authored-challenges/{challenge_id}/operator-revisions", 563 data=data, 564 files=files or None, 565 ) 566 _raise_for_status(resp) 567 return AuthoredOperatorRevisionInfo.model_validate(resp.json()) 568 569 def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo: 570 """Whether an upload may graduate into the corpus, and as what (plan P3.3). 571 572 ``refusals`` names every reason it may not yet — empty when it may. 573 ``corpus_id`` is the id it would take: the one passed, or else the 574 next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The 575 pull request's title and body are what a person opens it with. 576 """ 577 params = {"corpus_id": corpus_id} if corpus_id else None 578 resp = self._http.request( 579 "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params 580 ) 581 _raise_for_status(resp) 582 return AuthoredGraduationInfo.model_validate(resp.json()) 583 584 def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes: 585 """The ``.tar.gz`` to unpack at the corpus repository's root. 586 587 It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the 588 ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is 589 recorded on the platform — opening the pull request is a person's 590 act, in another repository. 591 """ 592 resp = self._http.request( 593 "GET", 594 f"/admin/authored-challenges/{challenge_id}/graduation/bundle", 595 params={"corpus_id": corpus_id}, 596 ) 597 _raise_for_status(resp) 598 return resp.content 599 600 def review( 601 self, 602 challenge_id: str, 603 *, 604 status: ReviewStatus, 605 note: str = "", 606 exec_surface_reviewed: bool = False, 607 ) -> AdminAuthoredChallengeRow: 608 """Record a verdict. ``rejected`` requires a note. 609 610 ``pending`` withdraws a verdict and clears its trail — a 611 mis-clicked approval is undoable without deleting the upload. 612 ``exec_surface_reviewed`` (#3337) confirms, with an approval, that 613 you read what the tree runs; with your verdict that is a person's 614 release, which replaces an automatic review's policy release. 615 """ 616 resp = self._http.request( 617 "POST", 618 f"/admin/authored-challenges/{challenge_id}/review", 619 json={"status": status, "note": note}, 620 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 621 ) 622 _raise_for_status(resp) 623 return AdminAuthoredChallengeRow.model_validate(resp.json()) 624 625 def push(self, challenge_id: str) -> AdminTaskInfo: 626 """Send an approved upload to every node marked for untrusted builds. 627 628 ⚠️ **The verb after `publish`, and they are not the same act.** 629 `publish` writes the tree into *this deployment's* catalog root; 630 a multi-host node's catalog is a different directory on a 631 different machine, and until this runs the node answers 404 for 632 the id the platform now lists. 633 634 Returns the task: the push is per node, and which node refused 635 is on its log rather than in one summary line. 636 """ 637 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push") 638 _raise_for_status(resp) 639 return AdminTaskInfo.model_validate(resp.json()) 640 641 def smoke( 642 self, challenge_id: str, *, build_network: BuildNetwork | None = None 643 ) -> AdminTaskInfo: 644 """Run this upload's own reference exploit against it, on a node. 645 646 ⚠️ **The machine check an approval cannot make.** The audit is 647 static and a reviewer reads files; only a run says whether the 648 estate comes up and the exploit captures what it claims. 649 650 Returns the task — the verdict is on its log, and it is 651 deliberately four-valued: *solved*, *unsolved*, *estate_failed*, 652 *malformed*, plus *errored* for a node that could not tell. 653 654 #2873: ``build_network`` (``online`` / ``offline``) is whether this 655 run's build ``RUN`` steps reach the network; ``None`` is the upload's 656 competition's setting. 657 """ 658 resp = self._http.request( 659 "POST", 660 f"/admin/authored-challenges/{challenge_id}/smoke", 661 params={"build_network": build_network} if build_network else None, 662 ) 663 _raise_for_status(resp) 664 return AdminTaskInfo.model_validate(resp.json()) 665 666 def resmoke_platform_faults( 667 self, *, competition_id: str = "", dry_run: bool = False 668 ) -> AdminTaskInfo: 669 """Re-smoke every upload #2029 mislabelled, once (the operator one-shot). 670 671 A dropped execute bit and a run-time ``pip install`` on a no-egress 672 smoke network recorded the author's ``unsolved`` / ``estate_failed``; 673 both are fixed. This enqueues the backfill, which selects those 674 uploads, re-runs each uncharged, and mails each author once that the 675 earlier failure was the platform's. ``dry_run`` reports the selection 676 and runs nothing. Returns the task. 677 """ 678 resp = self._http.request( 679 "POST", 680 "/admin/authored-challenges/resmoke-platform-faults", 681 params={"competition_id": competition_id, "dry_run": dry_run}, 682 ) 683 _raise_for_status(resp) 684 return AdminTaskInfo.model_validate(resp.json()) 685 686 def strays(self) -> builtins.list[AuthoredStrayRow]: 687 """Every stored challenge id a competition may not hold (#2553): an 688 invitation-only event's upload in another event's list or map — what 689 :meth:`strip_strays` removes. Read-only; empty is clean. (Before an 690 upgrade the check is offline: ``ctfy-admin admin authored strays --db``.)""" 691 resp = self._http.request("GET", "/admin/authored-challenges/strays") 692 _raise_for_status(resp) 693 return [AuthoredStrayRow.model_validate(row) for row in resp.json()] 694 695 def strip_strays(self, *, apply: bool = False) -> AuthoredStripResult: 696 """Remove every stray (#2553) — a dry run unless ``apply``. Each 697 removal is recorded with the platform as actor and keeps its solves' 698 points; the boxes it left behind are reclaimed.""" 699 resp = self._http.request( 700 "POST", 701 "/admin/authored-challenges/strays/strip", 702 params={"apply": "true" if apply else "false"}, 703 ) 704 _raise_for_status(resp) 705 return AuthoredStripResult.model_validate(resp.json()) 706 707 def publish( 708 self, challenge_id: str, *, exec_surface_reviewed: bool = False 709 ) -> AdminAuthoredChallengeRow: 710 """Write an approved upload into the deployment's catalog. 711 712 A separate verb from :meth:`review` on purpose: approving is a 713 person's verdict on somebody else's code, and it must not fail 714 because a corpus commit landed on the id in the days since. The 715 409 this raises carries every reason at once — the id is taken, 716 the row is not approved, the tree has no ``metadata.yaml`` — so a 717 caller fixes them in one pass rather than one press at a time. 718 719 ``exec_surface_reviewed`` confirms you read the files the tree 720 runs on a node or hands players to run (``executes`` on 721 :meth:`get`'s file list, plan P2.3). A tree with such files is 722 refused without it — once; the stamp then records who read it. 723 724 The row that comes back is **read back from the store**, so its 725 ``in_catalog`` is a fact about the disk after the write. 726 """ 727 resp = self._http.request( 728 "POST", 729 f"/admin/authored-challenges/{challenge_id}/publish", 730 # Only what was said: absent is the server's own default. 731 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 732 ) 733 _raise_for_status(resp) 734 return AdminAuthoredChallengeRow.model_validate(resp.json()) 735 736 def release_to_catalog( 737 self, challenge_id: str, *, exec_surface_reviewed: bool = False 738 ) -> AdminAuthoredChallengeRow: 739 """Put a published upload in the **public catalog** (#1989). 740 741 ⚠️ **Publishing is not this.** A published upload is seen only in 742 the competitions that curate it — by their players once each 743 starts, their organisers, its authors and platform admins. This is 744 the separate step that lists it to everybody; who released it and 745 when lands on the row (``catalog_released_by`` / 746 ``catalog_released_at``), and a second call changes nothing. 747 748 A tree an automatic review released is refused (409, with reasons) 749 until a person has recorded a content verdict on it (:meth:`review`) 750 and a platform admin has read what it runs — ``exec_surface_reviewed`` 751 confirms the second on this call. 752 """ 753 resp = self._http.request( 754 "POST", 755 f"/admin/authored-challenges/{challenge_id}/catalog-release", 756 # Only what was said: absent is the server's own default. 757 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 758 ) 759 _raise_for_status(resp) 760 return AdminAuthoredChallengeRow.model_validate(resp.json()) 761 762 def retire( 763 self, challenge_id: str, *, scores: AuthoredScoresChoice | None = None 764 ) -> AdminAuthoredChallengeRow: 765 """Stop offering an upload; its history is kept (#2760). 766 767 Platform admins only. It leaves every competition and is no longer 768 open to everyone; running environments run out on their own. A call 769 on an upload already retired changes nothing. 770 771 ``scores`` (#2991) decides the points already scored on it: ``kept`` 772 (and ``None``, the server's default) or ``removed`` — see 773 :meth:`points_at_stake` for what each would touch. 774 """ 775 resp = self._http.request( 776 "POST", 777 f"/admin/authored-challenges/{challenge_id}/retire", 778 params={"scores": scores} if scores else None, 779 ) 780 _raise_for_status(resp) 781 return AdminAuthoredChallengeRow.model_validate(resp.json()) 782 783 def unretire(self, challenge_id: str) -> AdminAuthoredChallengeRow: 784 """Offer a retired upload again (#2760): back to approved with no 785 competition using it. A call on one that is not retired changes 786 nothing.""" 787 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/unretire") 788 _raise_for_status(resp) 789 return AdminAuthoredChallengeRow.model_validate(resp.json()) 790 791 def other_uploads(self, challenge_id: str) -> AuthoredOtherUploads: 792 """The other uploads of this upload's challenge (#3110): same 793 competition, same title, same team or author — and the one approved 794 and smoked clean, suggested as the one to keep.""" 795 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/other-uploads") 796 _raise_for_status(resp) 797 return AuthoredOtherUploads.model_validate(resp.json()) 798 799 def merge( 800 self, challenge_id: str, *, into: str, reason: str, confirm: bool = False 801 ) -> AdminAuthoredChallengeDetail: 802 """Fold an upload into another upload of the same challenge (#3110): 803 recorded as ``merged`` with ``reason``, and taken out of every 804 competition's set (points scored there kept). Platform admins only. 805 Refused while anything is in play; ``confirm`` lifts only a running 806 event that would lose the challenge.""" 807 params: dict[str, str] = {"into": into, "reason": reason} 808 if confirm: 809 params["confirm"] = "true" 810 resp = self._http.request( 811 "POST", f"/admin/authored-challenges/{challenge_id}/merge", params=params 812 ) 813 _raise_for_status(resp) 814 return AdminAuthoredChallengeDetail.model_validate(resp.json()) 815 816 def unmerge(self, challenge_id: str, *, reason: str = "") -> AdminAuthoredChallengeDetail: 817 """Undo a merge (#3110): the upload is its own challenge again; no 818 competition comes back by itself.""" 819 resp = self._http.request( 820 "POST", 821 f"/admin/authored-challenges/{challenge_id}/unmerge", 822 params={"reason": reason} if reason else None, 823 ) 824 _raise_for_status(resp) 825 return AdminAuthoredChallengeDetail.model_validate(resp.json()) 826 827 def unrelease_from_catalog(self, challenge_id: str) -> AdminAuthoredChallengeRow: 828 """Withdraw an upload's public-catalog release (#2759): no longer 829 open to everyone, only the competitions using it. 830 831 Platform admins only. Every competition use is kept — an upload no 832 competition uses is then seen by nobody — and environments running 833 outside a competition run out on their own. Recorded as an 834 ``unreleased`` lifecycle record; a call on an upload that is not 835 open to everyone changes nothing. 836 """ 837 resp = self._http.request( 838 "POST", f"/admin/authored-challenges/{challenge_id}/catalog-unrelease" 839 ) 840 _raise_for_status(resp) 841 return AdminAuthoredChallengeRow.model_validate(resp.json()) 842 843 def withdraw_policy_release(self, challenge_id: str) -> AdminAuthoredChallengeRow: 844 """Withdraw the policy release an automatic review wrote (#3337): 845 the tree is no longer held to its event's dedicated pool, and starts 846 nowhere until a person approves it with ``exec_surface_reviewed`` 847 (:meth:`review`). Platform admins only; recorded as 848 ``policy_unreleased``; a call with no release changes nothing. 849 """ 850 resp = self._http.request( 851 "POST", f"/admin/authored-challenges/{challenge_id}/policy-unrelease" 852 ) 853 _raise_for_status(resp) 854 return AdminAuthoredChallengeRow.model_validate(resp.json()) 855 856 def points_at_stake( 857 self, challenge_id: str, action: AuthoredFlowAction, competition_id: str = "" 858 ) -> AuthoredPointsAtStake: 859 """What removing an upload would decide about the points already 860 scored on it (#2991): for ``uncurate`` (in ``competition_id``, or its 861 own), ``retire`` or ``unpublish``, the ranked solves and teams at 862 stake and the effects of keeping and of removing them.""" 863 params: dict[str, str] = {"action": action} 864 if competition_id: 865 params["competition_id"] = competition_id 866 resp = self._http.request( 867 "GET", f"/admin/authored-challenges/{challenge_id}/points", params=params 868 ) 869 _raise_for_status(resp) 870 return AuthoredPointsAtStake.model_validate(resp.json()) 871 872 def unpublish( 873 self, 874 challenge_id: str, 875 *, 876 reason: str, 877 scores: AuthoredScoresChoice | None = None, 878 ) -> AdminAuthoredChallengeRow: 879 """Take a published challenge back out of the deployment's catalog. 880 881 ``reason`` is required (``422`` with ``unpublish_needs_reason`` when 882 blank): the take-down is recorded — who, when, why — on the upload's 883 publication history (``AdminAuthoredChallengeDetail.lifecycle``, 884 #1965 H), which the authoring board reads. 885 886 :meth:`publish` had no inverse, so an upload that turned out to 887 leak its own answer stayed in front of every entrant and the only 888 recourse was a shell on the platform host. 889 890 It deletes the *publication*, never the row: the author's tree, 891 the reviewer's verdict and the smoke result all survive, and 892 publishing again is the same call it always was. The boxes already 893 running on it are stopped too (the route's ``stop_instances`` 894 defaults on and this call does not send it), so nobody new can 895 launch and nobody keeps playing it. It also leaves every competition 896 that uses it (#2740 D7); allowing it again adds it back to none. 897 898 Raises 409 when the deployment's catalog does not hold the id, 899 which is the mirror of publishing something already published. 900 901 ``scores`` (#2991): ``removed`` takes the points already scored on it 902 off the board; ``kept`` and ``None`` keep them. 903 """ 904 body: dict[str, str] = {"reason": reason} 905 if scores: 906 body["scores"] = scores 907 resp = self._http.request( 908 "POST", 909 f"/admin/authored-challenges/{challenge_id}/unpublish", 910 json=body, 911 ) 912 _raise_for_status(resp) 913 return AdminAuthoredChallengeRow.model_validate(resp.json()) 914 915 def curate( 916 self, challenge_id: str, competition_id: str = "", *, reason: str = "" 917 ) -> AdminAuthoredCurationRow: 918 """Put a published upload in front of this competition's entrants. 919 920 ⚠️ **The step after `publish`, and the two are not one act.** 921 Publishing writes the tree into this deployment's catalog; 922 curating adds its id to *one competition's* challenge set. Until 923 this existed the only way to take it was the competition edit 924 form, which re-sends the whole list — so two organisers working a 925 review queue dropped each other's additions. This appends. 926 927 Refused (409, with reasons) for a competition whose set comes 928 from a catalog bucket, and for an id this deployment has not 929 published: the second would put a challenge in front of entrants 930 that no node can start. 931 932 ``competition_id`` names another competition to curate it into — 933 the workshop → arena step — and every refusal is then asked of 934 that one. Empty is the upload's own competition. 935 936 ``warnings`` on the answer names what the target would refuse at its 937 own upload door and this verb does not (#1965): a static answer 938 curated into a competition reviewed automatically. 939 940 ``reason`` confirms the change for a competition that locks its 941 challenges while in play (#2041) — refused 942 (``curation_locked_needs_reason``) without one, and recorded with it. 943 """ 944 resp = self._http.request( 945 "POST", 946 f"/admin/authored-challenges/{challenge_id}/curation", 947 params=_curation_params(competition_id, reason), 948 ) 949 _raise_for_status(resp) 950 return AdminAuthoredCurationRow.model_validate(resp.json()) 951 952 def uncurate( 953 self, 954 challenge_id: str, 955 competition_id: str = "", 956 *, 957 reason: str = "", 958 scores: AuthoredScoresChoice | None = None, 959 ) -> AdminAuthoredCurationRow: 960 """Take an upload back out of this competition's challenge set. 961 962 Carries neither of :meth:`curate`'s refusals: removing an id from 963 a set that should not hold it is the repair, so gating it on the 964 same conditions would keep the broken state. ``competition_id`` 965 names which competition; empty is the upload's own. ``reason`` is 966 :meth:`curate`'s, for a locked competition in play (#2041). 967 968 ``scores`` (#2991): ``kept`` keeps the points already scored there; 969 ``removed`` and ``None`` (the removal as it always was) do not. 970 """ 971 params = _curation_params(competition_id, reason) or {} 972 if scores: 973 params["scores"] = scores 974 resp = self._http.request( 975 "DELETE", 976 f"/admin/authored-challenges/{challenge_id}/curation", 977 params=params or None, 978 ) 979 _raise_for_status(resp) 980 return AdminAuthoredCurationRow.model_validate(resp.json()) 981 982 def bulk_curate( 983 self, 984 competition_id: str, 985 target_competition_ids: builtins.list[str], 986 *, 987 challenge_ids: builtins.list[str] | None = None, 988 lock_versions: bool = True, 989 dry_run: bool = False, 990 reason: str = "", 991 ) -> BulkCurationResponse: 992 """Add ``competition_id``'s approved uploads to each target (#2120). 993 994 Each (upload, target) pair is :meth:`curate`'s press, with its 995 refusals, and gets one ``outcome``; ``challenge_ids`` narrows the 996 uploads. ``lock_versions`` turns each target's version lock on after. 997 ``reason`` confirms changes to a target locked in play (#2041). 998 ``dry_run=True`` is the preview. 999 """ 1000 resp = self._http.request( 1001 "POST", 1002 f"/admin/competitions/{competition_id}/authored-challenges/bulk-curation", 1003 json={ 1004 "target_competition_ids": list(target_competition_ids), 1005 "challenge_ids": challenge_ids, 1006 "lock_versions": lock_versions, 1007 "dry_run": dry_run, 1008 "confirm": bool(reason), 1009 "reason": reason, 1010 }, 1011 ) 1012 _raise_for_status(resp) 1013 return BulkCurationResponse.model_validate(resp.json()) 1014 1015 def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]: 1016 """Every revision of one upload, newest first — the proposal, if 1017 any, and the history of those applied (plan P3.1).""" 1018 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions") 1019 _raise_for_status(resp) 1020 return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()] 1021 1022 def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff: 1023 """A proposed revision against the live tree — what a reviewer reads. 1024 1025 ⚠️ A changed file with an empty ``diff`` says why in ``omitted`` 1026 (``binary``, ``too_large``, ``over_budget``); read such a file 1027 whole with :meth:`read_revision_file`. ``needs_exec_review`` says 1028 whether :meth:`apply_revision` will ask you to confirm the 1029 execution-surface review. 1030 """ 1031 resp = self._http.request( 1032 "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff" 1033 ) 1034 _raise_for_status(resp) 1035 return AuthoredRevisionDiff.model_validate(resp.json()) 1036 1037 def read_revision_file( 1038 self, challenge_id: str, revision_id: str, path: str 1039 ) -> AuthoredChallengeFileBody: 1040 """One file of a revision, whole — attacker-authored text written 1041 to be run, like :meth:`read_file`'s.""" 1042 resp = self._http.request( 1043 "POST", 1044 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file", 1045 json={"path": path}, 1046 ) 1047 _raise_for_status(resp) 1048 return AuthoredChallengeFileBody.model_validate(resp.json()) 1049 1050 def review_revision( 1051 self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = "" 1052 ) -> AdminAuthoredRevisionRow: 1053 """Record a verdict on a proposed revision. ``rejected`` requires a 1054 note and ``pending`` withdraws a verdict, as for an upload. An 1055 applied revision is history and is refused (409).""" 1056 resp = self._http.request( 1057 "POST", 1058 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review", 1059 json={"status": status, "note": note}, 1060 ) 1061 _raise_for_status(resp) 1062 return AdminAuthoredRevisionRow.model_validate(resp.json()) 1063 1064 def apply_revision( 1065 self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False 1066 ) -> AdminAuthoredChallengeRow: 1067 """Make an approved revision the upload's live version (plan P3.1). 1068 1069 A published upload's directory is replaced whole — boxes already 1070 running keep the bytes they started with — and the catalog is 1071 reloaded. Nodes that hold the upload keep the previous tree until 1072 :meth:`push` sends the new one. 1073 1074 ``exec_surface_reviewed`` confirms you read the revision's files 1075 marked ``executes``: a published upload whose revision has any is 1076 refused without it, every time — the bytes are new. The 409 carries 1077 every reason at once. 1078 """ 1079 resp = self._http.request( 1080 "POST", 1081 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply", 1082 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 1083 ) 1084 _raise_for_status(resp) 1085 return AdminAuthoredChallengeRow.model_validate(resp.json()) 1086 1087 1088def _curation_params(competition_id: str, reason: str) -> dict[str, str] | None: 1089 """The curation verb's query: its target, and a locked event's reason. 1090 1091 A reason is the confirmation (#2041): an SDK caller who wrote one has 1092 decided, so it is sent with ``confirm`` rather than as a second flag. 1093 """ 1094 params: dict[str, str] = {} 1095 if competition_id: 1096 params["competition_id"] = competition_id 1097 if reason.strip(): 1098 params["confirm"] = "true" 1099 params["reason"] = reason 1100 return params or None
76class AdminAuthoredChallengesResource: 77 """List, read and rule on player-authored uploads.""" 78 79 def __init__(self, http: BaseHttpClient) -> None: 80 self._http = http 81 82 def list( 83 self, 84 *, 85 competition_id: str | None = None, 86 team_id: str | None = None, 87 review_status: ReviewStatus | None = None, 88 smoke: AuthoredSmokeFilter | None = None, 89 revision: ReviewStatus | None = None, 90 order: AuthoredQueueOrder | None = None, 91 destination: AuthoredDestination | None = None, 92 offset: int = 0, 93 limit: int = 50, 94 ) -> PagedList[AdminAuthoredChallengeRow]: 95 """The queue, oldest first. 96 97 ``None`` on a filter means *every*; ``""`` is a value, so 98 passing it asks for the standalone catalog rather than for 99 everything. 100 101 ``smoke`` narrows by what the smoke run found: a verdict, 102 ``not_run`` for none yet, or ``in_flight`` for a run queued or 103 running (each row's ``smoke_in_flight`` says which). The total is 104 counted over the same filter. 105 106 ``revision`` narrows to uploads whose proposed revision carries 107 that verdict (plan P3.1): ``pending`` waits for a reviewer, 108 ``approved`` for :meth:`apply_revision`. 109 110 ``order="reputation"`` (plan P3.5) puts first the uploads of 111 authors whose published challenges players received best — each 112 row's ``author_reputation`` — then every upload with no reading, 113 oldest first; ``None`` leaves the server's order, oldest first. It 114 is ordered before the page is cut, so ``offset`` pages through 115 that order. 116 117 ``destination`` narrows to where the author sent the upload (plan 118 P3.4): ``public`` or ``eval_only``, the evaluation pool. 119 """ 120 params: dict[str, object] = {"offset": offset, "limit": limit} 121 if competition_id is not None: 122 params["competition_id"] = competition_id 123 if team_id is not None: 124 params["team_id"] = team_id 125 if review_status is not None: 126 params["review_status"] = review_status 127 if smoke is not None: 128 params["smoke"] = smoke 129 if revision is not None: 130 params["revision"] = revision 131 if order is not None: 132 params["order"] = order 133 if destination is not None: 134 params["destination"] = destination 135 resp = self._http.request("GET", "/admin/authored-challenges", params=params) 136 _raise_for_status(resp) 137 return _extract_items(resp.json(), AdminAuthoredChallengeRow) 138 139 def queue_counts(self, *, competition_id: str | None = None) -> AuthoredQueueCounts: 140 """How many uploads each review tab holds and how long the longest 141 has waited — one request, counted server-side (#2037 B1).""" 142 params: dict[str, object] = {} 143 if competition_id is not None: 144 params["competition_id"] = competition_id 145 resp = self._http.request("GET", "/admin/authored-challenges/queue-counts", params=params) 146 _raise_for_status(resp) 147 return AuthoredQueueCounts.model_validate(resp.json()) 148 149 def build_capability(self) -> AuthoredBuildCapability: 150 """Whether an approval on this deployment can become a live challenge. 151 152 ⚠️ **Ask this before reading a verdict as a deployment.** An 153 ``approved`` row is a person saying they read the code; it is not 154 a challenge in front of anybody until this answers ``can_build``. 155 156 ⚠️ **This line used to end "and today it never does".** That was 157 true until `authored_sync` landed and is not any more: with a 158 node marked ``untrusted_builds`` the answer is now ``True``, and 159 a caller that still reads it as a constant would skip the one 160 check that distinguishes a deployment which can run an approved 161 upload from one which cannot. 162 163 ⚠️ **It answers for the deployment, never for a tree.** Whether 164 *this* challenge reached a node is `push`'s task and its 165 per-node lines. 166 """ 167 resp = self._http.request("GET", "/admin/authoring/build-capability") 168 _raise_for_status(resp) 169 return AuthoredBuildCapability.model_validate(resp.json()) 170 171 def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail: 172 """One upload, with the paths and sizes of what is in it.""" 173 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}") 174 _raise_for_status(resp) 175 return AdminAuthoredChallengeDetail.model_validate(resp.json()) 176 177 def flow(self, challenge_id: str) -> AuthoredFlow: 178 """Where this upload is in its lifecycle, what a platform admin can do 179 next, and every transition that got it here — names included (#2147).""" 180 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/flow") 181 _raise_for_status(resp) 182 return AuthoredFlow.model_validate(resp.json()) 183 184 def precheck_runs(self, challenge_id: str) -> PrecheckRunsInfo: 185 """The AI pre-check's managed-eval runs of this upload's pending 186 versions, newest first (#3189).""" 187 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs") 188 _raise_for_status(resp) 189 return PrecheckRunsInfo.model_validate(resp.json()) 190 191 def precheck_run_artifact(self, challenge_id: str, run_id: str) -> bytes: 192 """What one pre-check run's agent left behind, as ``.tar.gz`` bytes 193 (#3260). Agent-written content: read it, never serve it.""" 194 resp = self._http.request( 195 "GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs/{run_id}/artifact" 196 ) 197 _raise_for_status(resp) 198 return bytes(resp.content) 199 200 def history(self, challenge_id: str) -> AuthoredHistory: 201 """Everything that happened to this upload, oldest first, as one 202 stream (#3093): the transition log, every AI pre-check and every 203 injection review. Organisers and reviewers only.""" 204 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/history") 205 _raise_for_status(resp) 206 return AuthoredHistory.model_validate(resp.json()) 207 208 def precheck_reruns(self, competition_id: str) -> PrecheckRerunInfo: 209 """The event's re-check card (#3266): the two scopes' counts and the 210 newest batch. Organisers only.""" 211 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/precheck-reruns") 212 _raise_for_status(resp) 213 return PrecheckRerunInfo.model_validate(resp.json()) 214 215 def start_precheck_reruns( 216 self, competition_id: str, *, scope: RerunScope = "unprechecked" 217 ) -> PrecheckRerunInfo: 218 """Re-run the AI pre-check over the event's approved uploads in 219 *scope* (#3266). Evidence only: nothing it finds unpublishes one.""" 220 resp = self._http.request( 221 "POST", 222 f"/admin/competitions/{competition_id}/precheck-reruns", 223 json={"scope": scope}, 224 ) 225 _raise_for_status(resp) 226 return PrecheckRerunInfo.model_validate(resp.json()) 227 228 def stop_precheck_reruns(self, competition_id: str, rerun_id: str) -> PrecheckRerunInfo: 229 """Stop a running re-check batch; what finished is kept (#3266).""" 230 resp = self._http.request( 231 "POST", f"/admin/competitions/{competition_id}/precheck-reruns/{rerun_id}/stop" 232 ) 233 _raise_for_status(resp) 234 return PrecheckRerunInfo.model_validate(resp.json()) 235 236 def smoke_logs(self, challenge_id: str) -> builtins.list[AuthoredSmokeLogSummary]: 237 """The smoke runs kept for this upload, newest first (#2024) — the 238 same scrubbed steps its author reads. The whole set: retention, not 239 a page, bounds it.""" 240 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs") 241 _raise_for_status(resp) 242 return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()] 243 244 def versions(self, challenge_id: str) -> builtins.list[AdminAuthoredVersionRow]: 245 """Every stored version of this upload, newest first (#2037) — the 246 rows its author reads, plus who ruled on each. The whole set.""" 247 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/versions") 248 _raise_for_status(resp) 249 return [AdminAuthoredVersionRow.model_validate(row) for row in resp.json()] 250 251 def competitions(self, challenge_id: str) -> builtins.list[AuthoredCompetitionRow]: 252 """Every competition an upload is curated into, with what players did 253 there (#2037) — starts and solves, counted over the whole set.""" 254 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/competitions") 255 _raise_for_status(resp) 256 return [AuthoredCompetitionRow.model_validate(row) for row in resp.json()] 257 258 def version_diff( 259 self, challenge_id: str, *, from_version: str, to_version: str 260 ) -> AuthoredVersionDiff: 261 """Two versions of this upload compared (#2037) — the author's diff.""" 262 resp = self._http.request( 263 "GET", 264 f"/admin/authored-challenges/{challenge_id}/versions/diff", 265 params={"from_version": from_version, "to_version": to_version}, 266 ) 267 _raise_for_status(resp) 268 return AuthoredVersionDiff.model_validate(resp.json()) 269 270 def version_archive(self, challenge_id: str, version_id: str) -> bytes: 271 """One version as ``.tar.gz`` bytes, execute bits included (#2037).""" 272 resp = self._http.request( 273 "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/archive" 274 ) 275 _raise_for_status(resp) 276 return bytes(resp.content) 277 278 def version_switches(self, challenge_id: str) -> AuthoredVersionSwitchPlan: 279 """Every switch of this upload's live version, newest first, and the 280 running competitions holding it (#2037).""" 281 resp = self._http.request( 282 "GET", f"/admin/authored-challenges/{challenge_id}/version-switches" 283 ) 284 _raise_for_status(resp) 285 return AuthoredVersionSwitchPlan.model_validate(resp.json()) 286 287 def switch_version( 288 self, 289 challenge_id: str, 290 version_id: str, 291 *, 292 when: AuthoredVersionSwitchWhen = "now", 293 reason: str = "", 294 exec_surface_reviewed: bool = False, 295 force: bool = False, 296 force_reason: str = "", 297 ) -> AuthoredVersionSwitchInfo: 298 """Make a version live (#2037): an approved proposal is applied, an 299 earlier approved version rolled back to (``reason`` required). ``when`` 300 is ``now`` or ``after_competitions`` — applied automatically once no 301 running competition holds the challenge. Refused 302 ``authored_version_switch_refused`` with coded reasons. 303 304 ``force`` / ``force_reason`` push an immediate switch through a 305 competition that locks its challenges' versions while in play 306 (#2041): a platform administrator's act, and only with a reason.""" 307 resp = self._http.request( 308 "POST", 309 f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/switch", 310 json={ 311 "when": when, 312 "reason": reason, 313 "exec_surface_reviewed": exec_surface_reviewed, 314 "force": force, 315 "force_reason": force_reason, 316 }, 317 ) 318 _raise_for_status(resp) 319 return AuthoredVersionSwitchInfo.model_validate(resp.json()) 320 321 def cancel_switch(self, challenge_id: str, switch_id: str) -> AuthoredVersionSwitchInfo: 322 """Cancel a switch still waiting for its competitions to end (#2037).""" 323 resp = self._http.request( 324 "POST", 325 f"/admin/authored-challenges/{challenge_id}/version-switches/{switch_id}/cancel", 326 ) 327 _raise_for_status(resp) 328 return AuthoredVersionSwitchInfo.model_validate(resp.json()) 329 330 def version_messages( 331 self, challenge_id: str, version_id: str 332 ) -> builtins.list[AuthoredVersionMessage]: 333 """One version's thread between its authors and reviewers (#2037).""" 334 resp = self._http.request( 335 "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages" 336 ) 337 _raise_for_status(resp) 338 return [AuthoredVersionMessage.model_validate(row) for row in resp.json()] 339 340 def post_version_message( 341 self, challenge_id: str, version_id: str, body: str, *, as_review_group: bool = True 342 ) -> builtins.list[AuthoredVersionMessage]: 343 """Write to an upload's authors on one version's thread (#2037) — as 344 the review group unless ``as_review_group`` is false. The thread.""" 345 resp = self._http.request( 346 "POST", 347 f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages", 348 json={"body": body, "as_review_group": as_review_group}, 349 ) 350 _raise_for_status(resp) 351 return [AuthoredVersionMessage.model_validate(row) for row in resp.json()] 352 353 def version_storage(self) -> AuthoredVersionStorageInfo: 354 """What the version store holds (#2037): ``stored_bytes`` each 355 distinct file once, ``logical_bytes`` every version stored whole.""" 356 resp = self._http.request("GET", "/admin/authoring/version-storage") 357 _raise_for_status(resp) 358 return AuthoredVersionStorageInfo.model_validate(resp.json()) 359 360 def smoke_log(self, challenge_id: str, log_id: str) -> AuthoredSmokeLog: 361 """One smoke run of this upload, step by step (#2024).""" 362 resp = self._http.request( 363 "GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs/{log_id}" 364 ) 365 _raise_for_status(resp) 366 return AuthoredSmokeLog.model_validate(resp.json()) 367 368 def smoke_live(self, challenge_id: str) -> AuthoredSmokeLive: 369 """The smoke run of this upload that is queued or running now (#2145).""" 370 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-live") 371 _raise_for_status(resp) 372 return AuthoredSmokeLive.model_validate(resp.json()) 373 374 def cancel_smoke(self, challenge_id: str) -> AuthoredSmokeLive: 375 """Take back this upload's smoke run while it still waits (#2145).""" 376 resp = self._http.request( 377 "POST", f"/admin/authored-challenges/{challenge_id}/smoke-live/cancel" 378 ) 379 _raise_for_status(resp) 380 return AuthoredSmokeLive.model_validate(resp.json()) 381 382 def smoke_run_lines( 383 self, 384 challenge_id: str, 385 run_id: str, 386 key: str, 387 *, 388 before: int = 0, 389 after: int = 0, 390 limit: int = 200, 391 ) -> AuthoredSmokeStepLines: 392 """A window of one step's full output (#2145) — the same scrubbed 393 lines its author reads; cursors as ``client.authoring.smoke_run_lines``.""" 394 resp = self._http.request( 395 "GET", 396 f"/admin/authored-challenges/{challenge_id}/smoke-runs/{run_id}/lines", 397 params={"step": key, "before": before, "after": after, "limit": limit}, 398 ) 399 _raise_for_status(resp) 400 return AuthoredSmokeStepLines.model_validate(resp.json()) 401 402 def audit(self, challenge_id: str) -> AuthoredAuditReport: 403 """Run the challenge audit over this upload and report it. 404 405 ⚠️ **Evidence, never a gate.** :meth:`review` does not consult 406 this: a rule that refused an approval on a finding would make 407 the operator's judgement the audit's to overrule, and the audit 408 cannot see inside a `Dockerfile`'s `RUN` — which is the half a 409 person is reading the files for. 410 411 ⚠️ Read ``outcome`` before the counts. An empty ``findings`` 412 list means *nothing was found* only when it says ``ok``; 413 ``timed_out`` and ``failed`` are the audit declining to answer, 414 and reading either as clean is the whole reason the field 415 exists. 416 """ 417 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit") 418 _raise_for_status(resp) 419 return AuthoredAuditReport.model_validate(resp.json()) 420 421 def model_review(self, challenge_id: str) -> AdminModelReviewInfo: 422 """The model's reading of this upload and of its proposed revision 423 (#1965 D): the injection verdict with its full reasoning and the 424 spans it quoted, the advisory findings, and near copies. 425 426 ⚠️ Read ``effective_injection`` to know what the platform acts on — 427 ``injection`` is what the model said, and an override makes it 428 ``clean``. ``unknown`` is the platform's problem (``problem`` says 429 which) and never blocks. 430 """ 431 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/model-review") 432 _raise_for_status(resp) 433 return AdminModelReviewInfo.model_validate(resp.json()) 434 435 def run_model_review(self, challenge_id: str, *, revision_id: str = "") -> AdminTaskInfo: 436 """Ask the model to read this upload, or its proposed revision — 437 advice: the press decides nothing. 409 while one is in flight.""" 438 params = {"revision_id": revision_id} if revision_id else None 439 resp = self._http.request( 440 "POST", f"/admin/authored-challenges/{challenge_id}/model-review", params=params 441 ) 442 _raise_for_status(resp) 443 return AdminTaskInfo.model_validate(resp.json()) 444 445 def assistant_review(self, challenge_id: str) -> AdminProbeInfo: 446 """The reviewer assistant's newest run on this upload's live tree and 447 on its proposed revision (#2830) — advice for the person reviewing, 448 never a verdict. ``enabled`` is whether this deployment names a 449 model at all.""" 450 resp = self._http.request( 451 "GET", f"/admin/authored-challenges/{challenge_id}/assistant-review" 452 ) 453 _raise_for_status(resp) 454 return AdminProbeInfo.model_validate(resp.json()) 455 456 def rerun_assistant_review(self, challenge_id: str) -> AdminTaskInfo: 457 """Smoke this upload again with the AI pre-check, even though this 458 version was probed already (#2830, #3053). A rerun *is* a smoke, so it 459 takes :meth:`smoke`'s door (super-admin); ``409 authoring_probe_off`` 460 while the event chose no model, ``authoring_probe_not_isolated`` with 461 no quarantine or dedicated node, ``authoring_probe_budget_spent`` 462 once the version's cap is spent.""" 463 resp = self._http.request( 464 "POST", f"/admin/authored-challenges/{challenge_id}/assistant-review" 465 ) 466 _raise_for_status(resp) 467 return AdminTaskInfo.model_validate(resp.json()) 468 469 def override_model_review( 470 self, challenge_id: str, review_id: str, *, note: str 471 ) -> AdminModelReview: 472 """Say the text a review flagged is not an injection. ``note`` is 473 required — the audit's *why*. An upload or revision the verdict 474 rejected under automatic review is decided again.""" 475 resp = self._http.request( 476 "POST", 477 f"/admin/authored-challenges/{challenge_id}/model-review/{review_id}/override", 478 json={"note": note}, 479 ) 480 _raise_for_status(resp) 481 return AdminModelReview.model_validate(resp.json()) 482 483 def malice_review(self, challenge_id: str) -> AdminMaliceInfo: 484 """The malice check of this upload's live tree and of its proposed 485 revision (#3211): ``hold``, every reason, the rule hits, the model's 486 reading and quotes, and any ruling. Never calls a model.""" 487 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/malice-review") 488 _raise_for_status(resp) 489 return AdminMaliceInfo.model_validate(resp.json()) 490 491 def override_malice_review( 492 self, 493 challenge_id: str, 494 review_id: str, 495 *, 496 decision: Literal["clear", "confirm"], 497 note: str, 498 ) -> AdminMaliceStanding: 499 """Rule on a malice hold: ``clear`` releases it (a version the 500 automatic chain held is decided again), ``confirm`` keeps it. 501 ``note`` is required — the audit's *why*.""" 502 resp = self._http.request( 503 "POST", 504 f"/admin/authored-challenges/{challenge_id}/malice-review/{review_id}/override", 505 json={"decision": decision, "note": note}, 506 ) 507 _raise_for_status(resp) 508 return AdminMaliceStanding.model_validate(resp.json()) 509 510 def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody: 511 """One file's contents. 512 513 ⚠️ The body is **attacker-authored text written to be run**. 514 ``binary`` and ``truncated`` say what you are not looking at; 515 treat a ``False`` on either as the only claim that you are. 516 """ 517 # ⚠️ The path rides the body, never the URL (#1929): a WAF that 518 # refuses a URL ending in ``.ini`` does not read a JSON body. 519 resp = self._http.request( 520 "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path} 521 ) 522 _raise_for_status(resp) 523 return AuthoredChallengeFileBody.model_validate(resp.json()) 524 525 def operator_revise( 526 self, 527 challenge_id: str, 528 *, 529 archive: bytes | None = None, 530 put: Mapping[str, bytes] | None = None, 531 delete: builtins.list[str] | None = None, 532 note: str = "", 533 approve: bool = False, 534 replace_waiting: bool = False, 535 ) -> AuthoredOperatorRevisionInfo: 536 """Submit a new version of somebody else's upload on their behalf (#3002). 537 538 Exactly one way in: ``archive`` (a whole ``.tar.gz``), or edits on top 539 of the current tree — ``put`` maps a path to its new bytes, ``delete`` 540 names paths to remove. The tree meets every rule an upload's does; 541 the author stays the author, the version records you as 542 ``revised_by``, and the uploader is told which files changed. 543 ``approve`` approves it in the same press, under your name. 544 ``replace_waiting`` confirms replacing a revision the author already 545 has waiting (otherwise ``409 authored_operator_revision_replaces_proposal``). 546 """ 547 data: dict[str, str] = { 548 "note": note, 549 "approve": "true" if approve else "", 550 "replace_waiting": "true" if replace_waiting else "", 551 } 552 files: dict[str, tuple[str, bytes, str]] = {} 553 if archive is not None: 554 files["file"] = ("revision.tar.gz", archive, "application/gzip") 555 else: 556 edits = [ 557 {"path": path, "content_b64": base64.b64encode(body).decode()} 558 for path, body in (put or {}).items() 559 ] + [{"path": path, "content_b64": None} for path in (delete or [])] 560 data["edits"] = json.dumps(edits) 561 resp = self._http.request( 562 "POST", 563 f"/admin/authored-challenges/{challenge_id}/operator-revisions", 564 data=data, 565 files=files or None, 566 ) 567 _raise_for_status(resp) 568 return AuthoredOperatorRevisionInfo.model_validate(resp.json()) 569 570 def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo: 571 """Whether an upload may graduate into the corpus, and as what (plan P3.3). 572 573 ``refusals`` names every reason it may not yet — empty when it may. 574 ``corpus_id`` is the id it would take: the one passed, or else the 575 next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The 576 pull request's title and body are what a person opens it with. 577 """ 578 params = {"corpus_id": corpus_id} if corpus_id else None 579 resp = self._http.request( 580 "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params 581 ) 582 _raise_for_status(resp) 583 return AuthoredGraduationInfo.model_validate(resp.json()) 584 585 def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes: 586 """The ``.tar.gz`` to unpack at the corpus repository's root. 587 588 It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the 589 ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is 590 recorded on the platform — opening the pull request is a person's 591 act, in another repository. 592 """ 593 resp = self._http.request( 594 "GET", 595 f"/admin/authored-challenges/{challenge_id}/graduation/bundle", 596 params={"corpus_id": corpus_id}, 597 ) 598 _raise_for_status(resp) 599 return resp.content 600 601 def review( 602 self, 603 challenge_id: str, 604 *, 605 status: ReviewStatus, 606 note: str = "", 607 exec_surface_reviewed: bool = False, 608 ) -> AdminAuthoredChallengeRow: 609 """Record a verdict. ``rejected`` requires a note. 610 611 ``pending`` withdraws a verdict and clears its trail — a 612 mis-clicked approval is undoable without deleting the upload. 613 ``exec_surface_reviewed`` (#3337) confirms, with an approval, that 614 you read what the tree runs; with your verdict that is a person's 615 release, which replaces an automatic review's policy release. 616 """ 617 resp = self._http.request( 618 "POST", 619 f"/admin/authored-challenges/{challenge_id}/review", 620 json={"status": status, "note": note}, 621 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 622 ) 623 _raise_for_status(resp) 624 return AdminAuthoredChallengeRow.model_validate(resp.json()) 625 626 def push(self, challenge_id: str) -> AdminTaskInfo: 627 """Send an approved upload to every node marked for untrusted builds. 628 629 ⚠️ **The verb after `publish`, and they are not the same act.** 630 `publish` writes the tree into *this deployment's* catalog root; 631 a multi-host node's catalog is a different directory on a 632 different machine, and until this runs the node answers 404 for 633 the id the platform now lists. 634 635 Returns the task: the push is per node, and which node refused 636 is on its log rather than in one summary line. 637 """ 638 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push") 639 _raise_for_status(resp) 640 return AdminTaskInfo.model_validate(resp.json()) 641 642 def smoke( 643 self, challenge_id: str, *, build_network: BuildNetwork | None = None 644 ) -> AdminTaskInfo: 645 """Run this upload's own reference exploit against it, on a node. 646 647 ⚠️ **The machine check an approval cannot make.** The audit is 648 static and a reviewer reads files; only a run says whether the 649 estate comes up and the exploit captures what it claims. 650 651 Returns the task — the verdict is on its log, and it is 652 deliberately four-valued: *solved*, *unsolved*, *estate_failed*, 653 *malformed*, plus *errored* for a node that could not tell. 654 655 #2873: ``build_network`` (``online`` / ``offline``) is whether this 656 run's build ``RUN`` steps reach the network; ``None`` is the upload's 657 competition's setting. 658 """ 659 resp = self._http.request( 660 "POST", 661 f"/admin/authored-challenges/{challenge_id}/smoke", 662 params={"build_network": build_network} if build_network else None, 663 ) 664 _raise_for_status(resp) 665 return AdminTaskInfo.model_validate(resp.json()) 666 667 def resmoke_platform_faults( 668 self, *, competition_id: str = "", dry_run: bool = False 669 ) -> AdminTaskInfo: 670 """Re-smoke every upload #2029 mislabelled, once (the operator one-shot). 671 672 A dropped execute bit and a run-time ``pip install`` on a no-egress 673 smoke network recorded the author's ``unsolved`` / ``estate_failed``; 674 both are fixed. This enqueues the backfill, which selects those 675 uploads, re-runs each uncharged, and mails each author once that the 676 earlier failure was the platform's. ``dry_run`` reports the selection 677 and runs nothing. Returns the task. 678 """ 679 resp = self._http.request( 680 "POST", 681 "/admin/authored-challenges/resmoke-platform-faults", 682 params={"competition_id": competition_id, "dry_run": dry_run}, 683 ) 684 _raise_for_status(resp) 685 return AdminTaskInfo.model_validate(resp.json()) 686 687 def strays(self) -> builtins.list[AuthoredStrayRow]: 688 """Every stored challenge id a competition may not hold (#2553): an 689 invitation-only event's upload in another event's list or map — what 690 :meth:`strip_strays` removes. Read-only; empty is clean. (Before an 691 upgrade the check is offline: ``ctfy-admin admin authored strays --db``.)""" 692 resp = self._http.request("GET", "/admin/authored-challenges/strays") 693 _raise_for_status(resp) 694 return [AuthoredStrayRow.model_validate(row) for row in resp.json()] 695 696 def strip_strays(self, *, apply: bool = False) -> AuthoredStripResult: 697 """Remove every stray (#2553) — a dry run unless ``apply``. Each 698 removal is recorded with the platform as actor and keeps its solves' 699 points; the boxes it left behind are reclaimed.""" 700 resp = self._http.request( 701 "POST", 702 "/admin/authored-challenges/strays/strip", 703 params={"apply": "true" if apply else "false"}, 704 ) 705 _raise_for_status(resp) 706 return AuthoredStripResult.model_validate(resp.json()) 707 708 def publish( 709 self, challenge_id: str, *, exec_surface_reviewed: bool = False 710 ) -> AdminAuthoredChallengeRow: 711 """Write an approved upload into the deployment's catalog. 712 713 A separate verb from :meth:`review` on purpose: approving is a 714 person's verdict on somebody else's code, and it must not fail 715 because a corpus commit landed on the id in the days since. The 716 409 this raises carries every reason at once — the id is taken, 717 the row is not approved, the tree has no ``metadata.yaml`` — so a 718 caller fixes them in one pass rather than one press at a time. 719 720 ``exec_surface_reviewed`` confirms you read the files the tree 721 runs on a node or hands players to run (``executes`` on 722 :meth:`get`'s file list, plan P2.3). A tree with such files is 723 refused without it — once; the stamp then records who read it. 724 725 The row that comes back is **read back from the store**, so its 726 ``in_catalog`` is a fact about the disk after the write. 727 """ 728 resp = self._http.request( 729 "POST", 730 f"/admin/authored-challenges/{challenge_id}/publish", 731 # Only what was said: absent is the server's own default. 732 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 733 ) 734 _raise_for_status(resp) 735 return AdminAuthoredChallengeRow.model_validate(resp.json()) 736 737 def release_to_catalog( 738 self, challenge_id: str, *, exec_surface_reviewed: bool = False 739 ) -> AdminAuthoredChallengeRow: 740 """Put a published upload in the **public catalog** (#1989). 741 742 ⚠️ **Publishing is not this.** A published upload is seen only in 743 the competitions that curate it — by their players once each 744 starts, their organisers, its authors and platform admins. This is 745 the separate step that lists it to everybody; who released it and 746 when lands on the row (``catalog_released_by`` / 747 ``catalog_released_at``), and a second call changes nothing. 748 749 A tree an automatic review released is refused (409, with reasons) 750 until a person has recorded a content verdict on it (:meth:`review`) 751 and a platform admin has read what it runs — ``exec_surface_reviewed`` 752 confirms the second on this call. 753 """ 754 resp = self._http.request( 755 "POST", 756 f"/admin/authored-challenges/{challenge_id}/catalog-release", 757 # Only what was said: absent is the server's own default. 758 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 759 ) 760 _raise_for_status(resp) 761 return AdminAuthoredChallengeRow.model_validate(resp.json()) 762 763 def retire( 764 self, challenge_id: str, *, scores: AuthoredScoresChoice | None = None 765 ) -> AdminAuthoredChallengeRow: 766 """Stop offering an upload; its history is kept (#2760). 767 768 Platform admins only. It leaves every competition and is no longer 769 open to everyone; running environments run out on their own. A call 770 on an upload already retired changes nothing. 771 772 ``scores`` (#2991) decides the points already scored on it: ``kept`` 773 (and ``None``, the server's default) or ``removed`` — see 774 :meth:`points_at_stake` for what each would touch. 775 """ 776 resp = self._http.request( 777 "POST", 778 f"/admin/authored-challenges/{challenge_id}/retire", 779 params={"scores": scores} if scores else None, 780 ) 781 _raise_for_status(resp) 782 return AdminAuthoredChallengeRow.model_validate(resp.json()) 783 784 def unretire(self, challenge_id: str) -> AdminAuthoredChallengeRow: 785 """Offer a retired upload again (#2760): back to approved with no 786 competition using it. A call on one that is not retired changes 787 nothing.""" 788 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/unretire") 789 _raise_for_status(resp) 790 return AdminAuthoredChallengeRow.model_validate(resp.json()) 791 792 def other_uploads(self, challenge_id: str) -> AuthoredOtherUploads: 793 """The other uploads of this upload's challenge (#3110): same 794 competition, same title, same team or author — and the one approved 795 and smoked clean, suggested as the one to keep.""" 796 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/other-uploads") 797 _raise_for_status(resp) 798 return AuthoredOtherUploads.model_validate(resp.json()) 799 800 def merge( 801 self, challenge_id: str, *, into: str, reason: str, confirm: bool = False 802 ) -> AdminAuthoredChallengeDetail: 803 """Fold an upload into another upload of the same challenge (#3110): 804 recorded as ``merged`` with ``reason``, and taken out of every 805 competition's set (points scored there kept). Platform admins only. 806 Refused while anything is in play; ``confirm`` lifts only a running 807 event that would lose the challenge.""" 808 params: dict[str, str] = {"into": into, "reason": reason} 809 if confirm: 810 params["confirm"] = "true" 811 resp = self._http.request( 812 "POST", f"/admin/authored-challenges/{challenge_id}/merge", params=params 813 ) 814 _raise_for_status(resp) 815 return AdminAuthoredChallengeDetail.model_validate(resp.json()) 816 817 def unmerge(self, challenge_id: str, *, reason: str = "") -> AdminAuthoredChallengeDetail: 818 """Undo a merge (#3110): the upload is its own challenge again; no 819 competition comes back by itself.""" 820 resp = self._http.request( 821 "POST", 822 f"/admin/authored-challenges/{challenge_id}/unmerge", 823 params={"reason": reason} if reason else None, 824 ) 825 _raise_for_status(resp) 826 return AdminAuthoredChallengeDetail.model_validate(resp.json()) 827 828 def unrelease_from_catalog(self, challenge_id: str) -> AdminAuthoredChallengeRow: 829 """Withdraw an upload's public-catalog release (#2759): no longer 830 open to everyone, only the competitions using it. 831 832 Platform admins only. Every competition use is kept — an upload no 833 competition uses is then seen by nobody — and environments running 834 outside a competition run out on their own. Recorded as an 835 ``unreleased`` lifecycle record; a call on an upload that is not 836 open to everyone changes nothing. 837 """ 838 resp = self._http.request( 839 "POST", f"/admin/authored-challenges/{challenge_id}/catalog-unrelease" 840 ) 841 _raise_for_status(resp) 842 return AdminAuthoredChallengeRow.model_validate(resp.json()) 843 844 def withdraw_policy_release(self, challenge_id: str) -> AdminAuthoredChallengeRow: 845 """Withdraw the policy release an automatic review wrote (#3337): 846 the tree is no longer held to its event's dedicated pool, and starts 847 nowhere until a person approves it with ``exec_surface_reviewed`` 848 (:meth:`review`). Platform admins only; recorded as 849 ``policy_unreleased``; a call with no release changes nothing. 850 """ 851 resp = self._http.request( 852 "POST", f"/admin/authored-challenges/{challenge_id}/policy-unrelease" 853 ) 854 _raise_for_status(resp) 855 return AdminAuthoredChallengeRow.model_validate(resp.json()) 856 857 def points_at_stake( 858 self, challenge_id: str, action: AuthoredFlowAction, competition_id: str = "" 859 ) -> AuthoredPointsAtStake: 860 """What removing an upload would decide about the points already 861 scored on it (#2991): for ``uncurate`` (in ``competition_id``, or its 862 own), ``retire`` or ``unpublish``, the ranked solves and teams at 863 stake and the effects of keeping and of removing them.""" 864 params: dict[str, str] = {"action": action} 865 if competition_id: 866 params["competition_id"] = competition_id 867 resp = self._http.request( 868 "GET", f"/admin/authored-challenges/{challenge_id}/points", params=params 869 ) 870 _raise_for_status(resp) 871 return AuthoredPointsAtStake.model_validate(resp.json()) 872 873 def unpublish( 874 self, 875 challenge_id: str, 876 *, 877 reason: str, 878 scores: AuthoredScoresChoice | None = None, 879 ) -> AdminAuthoredChallengeRow: 880 """Take a published challenge back out of the deployment's catalog. 881 882 ``reason`` is required (``422`` with ``unpublish_needs_reason`` when 883 blank): the take-down is recorded — who, when, why — on the upload's 884 publication history (``AdminAuthoredChallengeDetail.lifecycle``, 885 #1965 H), which the authoring board reads. 886 887 :meth:`publish` had no inverse, so an upload that turned out to 888 leak its own answer stayed in front of every entrant and the only 889 recourse was a shell on the platform host. 890 891 It deletes the *publication*, never the row: the author's tree, 892 the reviewer's verdict and the smoke result all survive, and 893 publishing again is the same call it always was. The boxes already 894 running on it are stopped too (the route's ``stop_instances`` 895 defaults on and this call does not send it), so nobody new can 896 launch and nobody keeps playing it. It also leaves every competition 897 that uses it (#2740 D7); allowing it again adds it back to none. 898 899 Raises 409 when the deployment's catalog does not hold the id, 900 which is the mirror of publishing something already published. 901 902 ``scores`` (#2991): ``removed`` takes the points already scored on it 903 off the board; ``kept`` and ``None`` keep them. 904 """ 905 body: dict[str, str] = {"reason": reason} 906 if scores: 907 body["scores"] = scores 908 resp = self._http.request( 909 "POST", 910 f"/admin/authored-challenges/{challenge_id}/unpublish", 911 json=body, 912 ) 913 _raise_for_status(resp) 914 return AdminAuthoredChallengeRow.model_validate(resp.json()) 915 916 def curate( 917 self, challenge_id: str, competition_id: str = "", *, reason: str = "" 918 ) -> AdminAuthoredCurationRow: 919 """Put a published upload in front of this competition's entrants. 920 921 ⚠️ **The step after `publish`, and the two are not one act.** 922 Publishing writes the tree into this deployment's catalog; 923 curating adds its id to *one competition's* challenge set. Until 924 this existed the only way to take it was the competition edit 925 form, which re-sends the whole list — so two organisers working a 926 review queue dropped each other's additions. This appends. 927 928 Refused (409, with reasons) for a competition whose set comes 929 from a catalog bucket, and for an id this deployment has not 930 published: the second would put a challenge in front of entrants 931 that no node can start. 932 933 ``competition_id`` names another competition to curate it into — 934 the workshop → arena step — and every refusal is then asked of 935 that one. Empty is the upload's own competition. 936 937 ``warnings`` on the answer names what the target would refuse at its 938 own upload door and this verb does not (#1965): a static answer 939 curated into a competition reviewed automatically. 940 941 ``reason`` confirms the change for a competition that locks its 942 challenges while in play (#2041) — refused 943 (``curation_locked_needs_reason``) without one, and recorded with it. 944 """ 945 resp = self._http.request( 946 "POST", 947 f"/admin/authored-challenges/{challenge_id}/curation", 948 params=_curation_params(competition_id, reason), 949 ) 950 _raise_for_status(resp) 951 return AdminAuthoredCurationRow.model_validate(resp.json()) 952 953 def uncurate( 954 self, 955 challenge_id: str, 956 competition_id: str = "", 957 *, 958 reason: str = "", 959 scores: AuthoredScoresChoice | None = None, 960 ) -> AdminAuthoredCurationRow: 961 """Take an upload back out of this competition's challenge set. 962 963 Carries neither of :meth:`curate`'s refusals: removing an id from 964 a set that should not hold it is the repair, so gating it on the 965 same conditions would keep the broken state. ``competition_id`` 966 names which competition; empty is the upload's own. ``reason`` is 967 :meth:`curate`'s, for a locked competition in play (#2041). 968 969 ``scores`` (#2991): ``kept`` keeps the points already scored there; 970 ``removed`` and ``None`` (the removal as it always was) do not. 971 """ 972 params = _curation_params(competition_id, reason) or {} 973 if scores: 974 params["scores"] = scores 975 resp = self._http.request( 976 "DELETE", 977 f"/admin/authored-challenges/{challenge_id}/curation", 978 params=params or None, 979 ) 980 _raise_for_status(resp) 981 return AdminAuthoredCurationRow.model_validate(resp.json()) 982 983 def bulk_curate( 984 self, 985 competition_id: str, 986 target_competition_ids: builtins.list[str], 987 *, 988 challenge_ids: builtins.list[str] | None = None, 989 lock_versions: bool = True, 990 dry_run: bool = False, 991 reason: str = "", 992 ) -> BulkCurationResponse: 993 """Add ``competition_id``'s approved uploads to each target (#2120). 994 995 Each (upload, target) pair is :meth:`curate`'s press, with its 996 refusals, and gets one ``outcome``; ``challenge_ids`` narrows the 997 uploads. ``lock_versions`` turns each target's version lock on after. 998 ``reason`` confirms changes to a target locked in play (#2041). 999 ``dry_run=True`` is the preview. 1000 """ 1001 resp = self._http.request( 1002 "POST", 1003 f"/admin/competitions/{competition_id}/authored-challenges/bulk-curation", 1004 json={ 1005 "target_competition_ids": list(target_competition_ids), 1006 "challenge_ids": challenge_ids, 1007 "lock_versions": lock_versions, 1008 "dry_run": dry_run, 1009 "confirm": bool(reason), 1010 "reason": reason, 1011 }, 1012 ) 1013 _raise_for_status(resp) 1014 return BulkCurationResponse.model_validate(resp.json()) 1015 1016 def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]: 1017 """Every revision of one upload, newest first — the proposal, if 1018 any, and the history of those applied (plan P3.1).""" 1019 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions") 1020 _raise_for_status(resp) 1021 return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()] 1022 1023 def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff: 1024 """A proposed revision against the live tree — what a reviewer reads. 1025 1026 ⚠️ A changed file with an empty ``diff`` says why in ``omitted`` 1027 (``binary``, ``too_large``, ``over_budget``); read such a file 1028 whole with :meth:`read_revision_file`. ``needs_exec_review`` says 1029 whether :meth:`apply_revision` will ask you to confirm the 1030 execution-surface review. 1031 """ 1032 resp = self._http.request( 1033 "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff" 1034 ) 1035 _raise_for_status(resp) 1036 return AuthoredRevisionDiff.model_validate(resp.json()) 1037 1038 def read_revision_file( 1039 self, challenge_id: str, revision_id: str, path: str 1040 ) -> AuthoredChallengeFileBody: 1041 """One file of a revision, whole — attacker-authored text written 1042 to be run, like :meth:`read_file`'s.""" 1043 resp = self._http.request( 1044 "POST", 1045 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file", 1046 json={"path": path}, 1047 ) 1048 _raise_for_status(resp) 1049 return AuthoredChallengeFileBody.model_validate(resp.json()) 1050 1051 def review_revision( 1052 self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = "" 1053 ) -> AdminAuthoredRevisionRow: 1054 """Record a verdict on a proposed revision. ``rejected`` requires a 1055 note and ``pending`` withdraws a verdict, as for an upload. An 1056 applied revision is history and is refused (409).""" 1057 resp = self._http.request( 1058 "POST", 1059 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review", 1060 json={"status": status, "note": note}, 1061 ) 1062 _raise_for_status(resp) 1063 return AdminAuthoredRevisionRow.model_validate(resp.json()) 1064 1065 def apply_revision( 1066 self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False 1067 ) -> AdminAuthoredChallengeRow: 1068 """Make an approved revision the upload's live version (plan P3.1). 1069 1070 A published upload's directory is replaced whole — boxes already 1071 running keep the bytes they started with — and the catalog is 1072 reloaded. Nodes that hold the upload keep the previous tree until 1073 :meth:`push` sends the new one. 1074 1075 ``exec_surface_reviewed`` confirms you read the revision's files 1076 marked ``executes``: a published upload whose revision has any is 1077 refused without it, every time — the bytes are new. The 409 carries 1078 every reason at once. 1079 """ 1080 resp = self._http.request( 1081 "POST", 1082 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply", 1083 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 1084 ) 1085 _raise_for_status(resp) 1086 return AdminAuthoredChallengeRow.model_validate(resp.json())
List, read and rule on player-authored uploads.
82 def list( 83 self, 84 *, 85 competition_id: str | None = None, 86 team_id: str | None = None, 87 review_status: ReviewStatus | None = None, 88 smoke: AuthoredSmokeFilter | None = None, 89 revision: ReviewStatus | None = None, 90 order: AuthoredQueueOrder | None = None, 91 destination: AuthoredDestination | None = None, 92 offset: int = 0, 93 limit: int = 50, 94 ) -> PagedList[AdminAuthoredChallengeRow]: 95 """The queue, oldest first. 96 97 ``None`` on a filter means *every*; ``""`` is a value, so 98 passing it asks for the standalone catalog rather than for 99 everything. 100 101 ``smoke`` narrows by what the smoke run found: a verdict, 102 ``not_run`` for none yet, or ``in_flight`` for a run queued or 103 running (each row's ``smoke_in_flight`` says which). The total is 104 counted over the same filter. 105 106 ``revision`` narrows to uploads whose proposed revision carries 107 that verdict (plan P3.1): ``pending`` waits for a reviewer, 108 ``approved`` for :meth:`apply_revision`. 109 110 ``order="reputation"`` (plan P3.5) puts first the uploads of 111 authors whose published challenges players received best — each 112 row's ``author_reputation`` — then every upload with no reading, 113 oldest first; ``None`` leaves the server's order, oldest first. It 114 is ordered before the page is cut, so ``offset`` pages through 115 that order. 116 117 ``destination`` narrows to where the author sent the upload (plan 118 P3.4): ``public`` or ``eval_only``, the evaluation pool. 119 """ 120 params: dict[str, object] = {"offset": offset, "limit": limit} 121 if competition_id is not None: 122 params["competition_id"] = competition_id 123 if team_id is not None: 124 params["team_id"] = team_id 125 if review_status is not None: 126 params["review_status"] = review_status 127 if smoke is not None: 128 params["smoke"] = smoke 129 if revision is not None: 130 params["revision"] = revision 131 if order is not None: 132 params["order"] = order 133 if destination is not None: 134 params["destination"] = destination 135 resp = self._http.request("GET", "/admin/authored-challenges", params=params) 136 _raise_for_status(resp) 137 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.
139 def queue_counts(self, *, competition_id: str | None = None) -> AuthoredQueueCounts: 140 """How many uploads each review tab holds and how long the longest 141 has waited — one request, counted server-side (#2037 B1).""" 142 params: dict[str, object] = {} 143 if competition_id is not None: 144 params["competition_id"] = competition_id 145 resp = self._http.request("GET", "/admin/authored-challenges/queue-counts", params=params) 146 _raise_for_status(resp) 147 return AuthoredQueueCounts.model_validate(resp.json())
How many uploads each review tab holds and how long the longest has waited — one request, counted server-side (#2037 B1).
149 def build_capability(self) -> AuthoredBuildCapability: 150 """Whether an approval on this deployment can become a live challenge. 151 152 ⚠️ **Ask this before reading a verdict as a deployment.** An 153 ``approved`` row is a person saying they read the code; it is not 154 a challenge in front of anybody until this answers ``can_build``. 155 156 ⚠️ **This line used to end "and today it never does".** That was 157 true until `authored_sync` landed and is not any more: with a 158 node marked ``untrusted_builds`` the answer is now ``True``, and 159 a caller that still reads it as a constant would skip the one 160 check that distinguishes a deployment which can run an approved 161 upload from one which cannot. 162 163 ⚠️ **It answers for the deployment, never for a tree.** Whether 164 *this* challenge reached a node is `push`'s task and its 165 per-node lines. 166 """ 167 resp = self._http.request("GET", "/admin/authoring/build-capability") 168 _raise_for_status(resp) 169 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.
171 def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail: 172 """One upload, with the paths and sizes of what is in it.""" 173 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}") 174 _raise_for_status(resp) 175 return AdminAuthoredChallengeDetail.model_validate(resp.json())
One upload, with the paths and sizes of what is in it.
177 def flow(self, challenge_id: str) -> AuthoredFlow: 178 """Where this upload is in its lifecycle, what a platform admin can do 179 next, and every transition that got it here — names included (#2147).""" 180 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/flow") 181 _raise_for_status(resp) 182 return AuthoredFlow.model_validate(resp.json())
Where this upload is in its lifecycle, what a platform admin can do next, and every transition that got it here — names included (#2147).
184 def precheck_runs(self, challenge_id: str) -> PrecheckRunsInfo: 185 """The AI pre-check's managed-eval runs of this upload's pending 186 versions, newest first (#3189).""" 187 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs") 188 _raise_for_status(resp) 189 return PrecheckRunsInfo.model_validate(resp.json())
The AI pre-check's managed-eval runs of this upload's pending versions, newest first (#3189).
191 def precheck_run_artifact(self, challenge_id: str, run_id: str) -> bytes: 192 """What one pre-check run's agent left behind, as ``.tar.gz`` bytes 193 (#3260). Agent-written content: read it, never serve it.""" 194 resp = self._http.request( 195 "GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs/{run_id}/artifact" 196 ) 197 _raise_for_status(resp) 198 return bytes(resp.content)
What one pre-check run's agent left behind, as .tar.gz bytes
(#3260). Agent-written content: read it, never serve it.
200 def history(self, challenge_id: str) -> AuthoredHistory: 201 """Everything that happened to this upload, oldest first, as one 202 stream (#3093): the transition log, every AI pre-check and every 203 injection review. Organisers and reviewers only.""" 204 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/history") 205 _raise_for_status(resp) 206 return AuthoredHistory.model_validate(resp.json())
Everything that happened to this upload, oldest first, as one stream (#3093): the transition log, every AI pre-check and every injection review. Organisers and reviewers only.
208 def precheck_reruns(self, competition_id: str) -> PrecheckRerunInfo: 209 """The event's re-check card (#3266): the two scopes' counts and the 210 newest batch. Organisers only.""" 211 resp = self._http.request("GET", f"/admin/competitions/{competition_id}/precheck-reruns") 212 _raise_for_status(resp) 213 return PrecheckRerunInfo.model_validate(resp.json())
The event's re-check card (#3266): the two scopes' counts and the newest batch. Organisers only.
215 def start_precheck_reruns( 216 self, competition_id: str, *, scope: RerunScope = "unprechecked" 217 ) -> PrecheckRerunInfo: 218 """Re-run the AI pre-check over the event's approved uploads in 219 *scope* (#3266). Evidence only: nothing it finds unpublishes one.""" 220 resp = self._http.request( 221 "POST", 222 f"/admin/competitions/{competition_id}/precheck-reruns", 223 json={"scope": scope}, 224 ) 225 _raise_for_status(resp) 226 return PrecheckRerunInfo.model_validate(resp.json())
Re-run the AI pre-check over the event's approved uploads in scope (#3266). Evidence only: nothing it finds unpublishes one.
228 def stop_precheck_reruns(self, competition_id: str, rerun_id: str) -> PrecheckRerunInfo: 229 """Stop a running re-check batch; what finished is kept (#3266).""" 230 resp = self._http.request( 231 "POST", f"/admin/competitions/{competition_id}/precheck-reruns/{rerun_id}/stop" 232 ) 233 _raise_for_status(resp) 234 return PrecheckRerunInfo.model_validate(resp.json())
Stop a running re-check batch; what finished is kept (#3266).
236 def smoke_logs(self, challenge_id: str) -> builtins.list[AuthoredSmokeLogSummary]: 237 """The smoke runs kept for this upload, newest first (#2024) — the 238 same scrubbed steps its author reads. The whole set: retention, not 239 a page, bounds it.""" 240 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs") 241 _raise_for_status(resp) 242 return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()]
The smoke runs kept for this upload, newest first (#2024) — the same scrubbed steps its author reads. The whole set: retention, not a page, bounds it.
244 def versions(self, challenge_id: str) -> builtins.list[AdminAuthoredVersionRow]: 245 """Every stored version of this upload, newest first (#2037) — the 246 rows its author reads, plus who ruled on each. The whole set.""" 247 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/versions") 248 _raise_for_status(resp) 249 return [AdminAuthoredVersionRow.model_validate(row) for row in resp.json()]
Every stored version of this upload, newest first (#2037) — the rows its author reads, plus who ruled on each. The whole set.
251 def competitions(self, challenge_id: str) -> builtins.list[AuthoredCompetitionRow]: 252 """Every competition an upload is curated into, with what players did 253 there (#2037) — starts and solves, counted over the whole set.""" 254 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/competitions") 255 _raise_for_status(resp) 256 return [AuthoredCompetitionRow.model_validate(row) for row in resp.json()]
Every competition an upload is curated into, with what players did there (#2037) — starts and solves, counted over the whole set.
258 def version_diff( 259 self, challenge_id: str, *, from_version: str, to_version: str 260 ) -> AuthoredVersionDiff: 261 """Two versions of this upload compared (#2037) — the author's diff.""" 262 resp = self._http.request( 263 "GET", 264 f"/admin/authored-challenges/{challenge_id}/versions/diff", 265 params={"from_version": from_version, "to_version": to_version}, 266 ) 267 _raise_for_status(resp) 268 return AuthoredVersionDiff.model_validate(resp.json())
Two versions of this upload compared (#2037) — the author's diff.
270 def version_archive(self, challenge_id: str, version_id: str) -> bytes: 271 """One version as ``.tar.gz`` bytes, execute bits included (#2037).""" 272 resp = self._http.request( 273 "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/archive" 274 ) 275 _raise_for_status(resp) 276 return bytes(resp.content)
One version as .tar.gz bytes, execute bits included (#2037).
278 def version_switches(self, challenge_id: str) -> AuthoredVersionSwitchPlan: 279 """Every switch of this upload's live version, newest first, and the 280 running competitions holding it (#2037).""" 281 resp = self._http.request( 282 "GET", f"/admin/authored-challenges/{challenge_id}/version-switches" 283 ) 284 _raise_for_status(resp) 285 return AuthoredVersionSwitchPlan.model_validate(resp.json())
Every switch of this upload's live version, newest first, and the running competitions holding it (#2037).
287 def switch_version( 288 self, 289 challenge_id: str, 290 version_id: str, 291 *, 292 when: AuthoredVersionSwitchWhen = "now", 293 reason: str = "", 294 exec_surface_reviewed: bool = False, 295 force: bool = False, 296 force_reason: str = "", 297 ) -> AuthoredVersionSwitchInfo: 298 """Make a version live (#2037): an approved proposal is applied, an 299 earlier approved version rolled back to (``reason`` required). ``when`` 300 is ``now`` or ``after_competitions`` — applied automatically once no 301 running competition holds the challenge. Refused 302 ``authored_version_switch_refused`` with coded reasons. 303 304 ``force`` / ``force_reason`` push an immediate switch through a 305 competition that locks its challenges' versions while in play 306 (#2041): a platform administrator's act, and only with a reason.""" 307 resp = self._http.request( 308 "POST", 309 f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/switch", 310 json={ 311 "when": when, 312 "reason": reason, 313 "exec_surface_reviewed": exec_surface_reviewed, 314 "force": force, 315 "force_reason": force_reason, 316 }, 317 ) 318 _raise_for_status(resp) 319 return AuthoredVersionSwitchInfo.model_validate(resp.json())
Make a version live (#2037): an approved proposal is applied, an
earlier approved version rolled back to (reason required). when
is now or after_competitions — applied automatically once no
running competition holds the challenge. Refused
authored_version_switch_refused with coded reasons.
force / force_reason push an immediate switch through a
competition that locks its challenges' versions while in play
(#2041): a platform administrator's act, and only with a reason.
321 def cancel_switch(self, challenge_id: str, switch_id: str) -> AuthoredVersionSwitchInfo: 322 """Cancel a switch still waiting for its competitions to end (#2037).""" 323 resp = self._http.request( 324 "POST", 325 f"/admin/authored-challenges/{challenge_id}/version-switches/{switch_id}/cancel", 326 ) 327 _raise_for_status(resp) 328 return AuthoredVersionSwitchInfo.model_validate(resp.json())
Cancel a switch still waiting for its competitions to end (#2037).
330 def version_messages( 331 self, challenge_id: str, version_id: str 332 ) -> builtins.list[AuthoredVersionMessage]: 333 """One version's thread between its authors and reviewers (#2037).""" 334 resp = self._http.request( 335 "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages" 336 ) 337 _raise_for_status(resp) 338 return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
One version's thread between its authors and reviewers (#2037).
340 def post_version_message( 341 self, challenge_id: str, version_id: str, body: str, *, as_review_group: bool = True 342 ) -> builtins.list[AuthoredVersionMessage]: 343 """Write to an upload's authors on one version's thread (#2037) — as 344 the review group unless ``as_review_group`` is false. The thread.""" 345 resp = self._http.request( 346 "POST", 347 f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages", 348 json={"body": body, "as_review_group": as_review_group}, 349 ) 350 _raise_for_status(resp) 351 return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
Write to an upload's authors on one version's thread (#2037) — as
the review group unless as_review_group is false. The thread.
353 def version_storage(self) -> AuthoredVersionStorageInfo: 354 """What the version store holds (#2037): ``stored_bytes`` each 355 distinct file once, ``logical_bytes`` every version stored whole.""" 356 resp = self._http.request("GET", "/admin/authoring/version-storage") 357 _raise_for_status(resp) 358 return AuthoredVersionStorageInfo.model_validate(resp.json())
What the version store holds (#2037): stored_bytes each
distinct file once, logical_bytes every version stored whole.
360 def smoke_log(self, challenge_id: str, log_id: str) -> AuthoredSmokeLog: 361 """One smoke run of this upload, step by step (#2024).""" 362 resp = self._http.request( 363 "GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs/{log_id}" 364 ) 365 _raise_for_status(resp) 366 return AuthoredSmokeLog.model_validate(resp.json())
One smoke run of this upload, step by step (#2024).
368 def smoke_live(self, challenge_id: str) -> AuthoredSmokeLive: 369 """The smoke run of this upload that is queued or running now (#2145).""" 370 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-live") 371 _raise_for_status(resp) 372 return AuthoredSmokeLive.model_validate(resp.json())
The smoke run of this upload that is queued or running now (#2145).
374 def cancel_smoke(self, challenge_id: str) -> AuthoredSmokeLive: 375 """Take back this upload's smoke run while it still waits (#2145).""" 376 resp = self._http.request( 377 "POST", f"/admin/authored-challenges/{challenge_id}/smoke-live/cancel" 378 ) 379 _raise_for_status(resp) 380 return AuthoredSmokeLive.model_validate(resp.json())
Take back this upload's smoke run while it still waits (#2145).
382 def smoke_run_lines( 383 self, 384 challenge_id: str, 385 run_id: str, 386 key: str, 387 *, 388 before: int = 0, 389 after: int = 0, 390 limit: int = 200, 391 ) -> AuthoredSmokeStepLines: 392 """A window of one step's full output (#2145) — the same scrubbed 393 lines its author reads; cursors as ``client.authoring.smoke_run_lines``.""" 394 resp = self._http.request( 395 "GET", 396 f"/admin/authored-challenges/{challenge_id}/smoke-runs/{run_id}/lines", 397 params={"step": key, "before": before, "after": after, "limit": limit}, 398 ) 399 _raise_for_status(resp) 400 return AuthoredSmokeStepLines.model_validate(resp.json())
A window of one step's full output (#2145) — the same scrubbed
lines its author reads; cursors as client.authoring.smoke_run_lines.
402 def audit(self, challenge_id: str) -> AuthoredAuditReport: 403 """Run the challenge audit over this upload and report it. 404 405 ⚠️ **Evidence, never a gate.** :meth:`review` does not consult 406 this: a rule that refused an approval on a finding would make 407 the operator's judgement the audit's to overrule, and the audit 408 cannot see inside a `Dockerfile`'s `RUN` — which is the half a 409 person is reading the files for. 410 411 ⚠️ Read ``outcome`` before the counts. An empty ``findings`` 412 list means *nothing was found* only when it says ``ok``; 413 ``timed_out`` and ``failed`` are the audit declining to answer, 414 and reading either as clean is the whole reason the field 415 exists. 416 """ 417 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit") 418 _raise_for_status(resp) 419 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.
421 def model_review(self, challenge_id: str) -> AdminModelReviewInfo: 422 """The model's reading of this upload and of its proposed revision 423 (#1965 D): the injection verdict with its full reasoning and the 424 spans it quoted, the advisory findings, and near copies. 425 426 ⚠️ Read ``effective_injection`` to know what the platform acts on — 427 ``injection`` is what the model said, and an override makes it 428 ``clean``. ``unknown`` is the platform's problem (``problem`` says 429 which) and never blocks. 430 """ 431 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/model-review") 432 _raise_for_status(resp) 433 return AdminModelReviewInfo.model_validate(resp.json())
The model's reading of this upload and of its proposed revision (#1965 D): the injection verdict with its full reasoning and the spans it quoted, the advisory findings, and near copies.
⚠️ Read effective_injection to know what the platform acts on —
injection is what the model said, and an override makes it
clean. unknown is the platform's problem (problem says
which) and never blocks.
435 def run_model_review(self, challenge_id: str, *, revision_id: str = "") -> AdminTaskInfo: 436 """Ask the model to read this upload, or its proposed revision — 437 advice: the press decides nothing. 409 while one is in flight.""" 438 params = {"revision_id": revision_id} if revision_id else None 439 resp = self._http.request( 440 "POST", f"/admin/authored-challenges/{challenge_id}/model-review", params=params 441 ) 442 _raise_for_status(resp) 443 return AdminTaskInfo.model_validate(resp.json())
Ask the model to read this upload, or its proposed revision — advice: the press decides nothing. 409 while one is in flight.
445 def assistant_review(self, challenge_id: str) -> AdminProbeInfo: 446 """The reviewer assistant's newest run on this upload's live tree and 447 on its proposed revision (#2830) — advice for the person reviewing, 448 never a verdict. ``enabled`` is whether this deployment names a 449 model at all.""" 450 resp = self._http.request( 451 "GET", f"/admin/authored-challenges/{challenge_id}/assistant-review" 452 ) 453 _raise_for_status(resp) 454 return AdminProbeInfo.model_validate(resp.json())
The reviewer assistant's newest run on this upload's live tree and
on its proposed revision (#2830) — advice for the person reviewing,
never a verdict. enabled is whether this deployment names a
model at all.
456 def rerun_assistant_review(self, challenge_id: str) -> AdminTaskInfo: 457 """Smoke this upload again with the AI pre-check, even though this 458 version was probed already (#2830, #3053). A rerun *is* a smoke, so it 459 takes :meth:`smoke`'s door (super-admin); ``409 authoring_probe_off`` 460 while the event chose no model, ``authoring_probe_not_isolated`` with 461 no quarantine or dedicated node, ``authoring_probe_budget_spent`` 462 once the version's cap is spent.""" 463 resp = self._http.request( 464 "POST", f"/admin/authored-challenges/{challenge_id}/assistant-review" 465 ) 466 _raise_for_status(resp) 467 return AdminTaskInfo.model_validate(resp.json())
Smoke this upload again with the AI pre-check, even though this
version was probed already (#2830, #3053). A rerun is a smoke, so it
takes smoke()'s door (super-admin); 409 authoring_probe_off
while the event chose no model, authoring_probe_not_isolated with
no quarantine or dedicated node, authoring_probe_budget_spent
once the version's cap is spent.
469 def override_model_review( 470 self, challenge_id: str, review_id: str, *, note: str 471 ) -> AdminModelReview: 472 """Say the text a review flagged is not an injection. ``note`` is 473 required — the audit's *why*. An upload or revision the verdict 474 rejected under automatic review is decided again.""" 475 resp = self._http.request( 476 "POST", 477 f"/admin/authored-challenges/{challenge_id}/model-review/{review_id}/override", 478 json={"note": note}, 479 ) 480 _raise_for_status(resp) 481 return AdminModelReview.model_validate(resp.json())
Say the text a review flagged is not an injection. note is
required — the audit's why. An upload or revision the verdict
rejected under automatic review is decided again.
483 def malice_review(self, challenge_id: str) -> AdminMaliceInfo: 484 """The malice check of this upload's live tree and of its proposed 485 revision (#3211): ``hold``, every reason, the rule hits, the model's 486 reading and quotes, and any ruling. Never calls a model.""" 487 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/malice-review") 488 _raise_for_status(resp) 489 return AdminMaliceInfo.model_validate(resp.json())
The malice check of this upload's live tree and of its proposed
revision (#3211): hold, every reason, the rule hits, the model's
reading and quotes, and any ruling. Never calls a model.
491 def override_malice_review( 492 self, 493 challenge_id: str, 494 review_id: str, 495 *, 496 decision: Literal["clear", "confirm"], 497 note: str, 498 ) -> AdminMaliceStanding: 499 """Rule on a malice hold: ``clear`` releases it (a version the 500 automatic chain held is decided again), ``confirm`` keeps it. 501 ``note`` is required — the audit's *why*.""" 502 resp = self._http.request( 503 "POST", 504 f"/admin/authored-challenges/{challenge_id}/malice-review/{review_id}/override", 505 json={"decision": decision, "note": note}, 506 ) 507 _raise_for_status(resp) 508 return AdminMaliceStanding.model_validate(resp.json())
Rule on a malice hold: clear releases it (a version the
automatic chain held is decided again), confirm keeps it.
note is required — the audit's why.
510 def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody: 511 """One file's contents. 512 513 ⚠️ The body is **attacker-authored text written to be run**. 514 ``binary`` and ``truncated`` say what you are not looking at; 515 treat a ``False`` on either as the only claim that you are. 516 """ 517 # ⚠️ The path rides the body, never the URL (#1929): a WAF that 518 # refuses a URL ending in ``.ini`` does not read a JSON body. 519 resp = self._http.request( 520 "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path} 521 ) 522 _raise_for_status(resp) 523 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.
525 def operator_revise( 526 self, 527 challenge_id: str, 528 *, 529 archive: bytes | None = None, 530 put: Mapping[str, bytes] | None = None, 531 delete: builtins.list[str] | None = None, 532 note: str = "", 533 approve: bool = False, 534 replace_waiting: bool = False, 535 ) -> AuthoredOperatorRevisionInfo: 536 """Submit a new version of somebody else's upload on their behalf (#3002). 537 538 Exactly one way in: ``archive`` (a whole ``.tar.gz``), or edits on top 539 of the current tree — ``put`` maps a path to its new bytes, ``delete`` 540 names paths to remove. The tree meets every rule an upload's does; 541 the author stays the author, the version records you as 542 ``revised_by``, and the uploader is told which files changed. 543 ``approve`` approves it in the same press, under your name. 544 ``replace_waiting`` confirms replacing a revision the author already 545 has waiting (otherwise ``409 authored_operator_revision_replaces_proposal``). 546 """ 547 data: dict[str, str] = { 548 "note": note, 549 "approve": "true" if approve else "", 550 "replace_waiting": "true" if replace_waiting else "", 551 } 552 files: dict[str, tuple[str, bytes, str]] = {} 553 if archive is not None: 554 files["file"] = ("revision.tar.gz", archive, "application/gzip") 555 else: 556 edits = [ 557 {"path": path, "content_b64": base64.b64encode(body).decode()} 558 for path, body in (put or {}).items() 559 ] + [{"path": path, "content_b64": None} for path in (delete or [])] 560 data["edits"] = json.dumps(edits) 561 resp = self._http.request( 562 "POST", 563 f"/admin/authored-challenges/{challenge_id}/operator-revisions", 564 data=data, 565 files=files or None, 566 ) 567 _raise_for_status(resp) 568 return AuthoredOperatorRevisionInfo.model_validate(resp.json())
Submit a new version of somebody else's upload on their behalf (#3002).
Exactly one way in: archive (a whole .tar.gz), or edits on top
of the current tree — put maps a path to its new bytes, delete
names paths to remove. The tree meets every rule an upload's does;
the author stays the author, the version records you as
revised_by, and the uploader is told which files changed.
approve approves it in the same press, under your name.
replace_waiting confirms replacing a revision the author already
has waiting (otherwise 409 authored_operator_revision_replaces_proposal).
570 def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo: 571 """Whether an upload may graduate into the corpus, and as what (plan P3.3). 572 573 ``refusals`` names every reason it may not yet — empty when it may. 574 ``corpus_id`` is the id it would take: the one passed, or else the 575 next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The 576 pull request's title and body are what a person opens it with. 577 """ 578 params = {"corpus_id": corpus_id} if corpus_id else None 579 resp = self._http.request( 580 "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params 581 ) 582 _raise_for_status(resp) 583 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.
585 def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes: 586 """The ``.tar.gz`` to unpack at the corpus repository's root. 587 588 It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the 589 ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is 590 recorded on the platform — opening the pull request is a person's 591 act, in another repository. 592 """ 593 resp = self._http.request( 594 "GET", 595 f"/admin/authored-challenges/{challenge_id}/graduation/bundle", 596 params={"corpus_id": corpus_id}, 597 ) 598 _raise_for_status(resp) 599 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.
601 def review( 602 self, 603 challenge_id: str, 604 *, 605 status: ReviewStatus, 606 note: str = "", 607 exec_surface_reviewed: bool = False, 608 ) -> AdminAuthoredChallengeRow: 609 """Record a verdict. ``rejected`` requires a note. 610 611 ``pending`` withdraws a verdict and clears its trail — a 612 mis-clicked approval is undoable without deleting the upload. 613 ``exec_surface_reviewed`` (#3337) confirms, with an approval, that 614 you read what the tree runs; with your verdict that is a person's 615 release, which replaces an automatic review's policy release. 616 """ 617 resp = self._http.request( 618 "POST", 619 f"/admin/authored-challenges/{challenge_id}/review", 620 json={"status": status, "note": note}, 621 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 622 ) 623 _raise_for_status(resp) 624 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.
exec_surface_reviewed (#3337) confirms, with an approval, that
you read what the tree runs; with your verdict that is a person's
release, which replaces an automatic review's policy release.
626 def push(self, challenge_id: str) -> AdminTaskInfo: 627 """Send an approved upload to every node marked for untrusted builds. 628 629 ⚠️ **The verb after `publish`, and they are not the same act.** 630 `publish` writes the tree into *this deployment's* catalog root; 631 a multi-host node's catalog is a different directory on a 632 different machine, and until this runs the node answers 404 for 633 the id the platform now lists. 634 635 Returns the task: the push is per node, and which node refused 636 is on its log rather than in one summary line. 637 """ 638 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push") 639 _raise_for_status(resp) 640 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.
642 def smoke( 643 self, challenge_id: str, *, build_network: BuildNetwork | None = None 644 ) -> AdminTaskInfo: 645 """Run this upload's own reference exploit against it, on a node. 646 647 ⚠️ **The machine check an approval cannot make.** The audit is 648 static and a reviewer reads files; only a run says whether the 649 estate comes up and the exploit captures what it claims. 650 651 Returns the task — the verdict is on its log, and it is 652 deliberately four-valued: *solved*, *unsolved*, *estate_failed*, 653 *malformed*, plus *errored* for a node that could not tell. 654 655 #2873: ``build_network`` (``online`` / ``offline``) is whether this 656 run's build ``RUN`` steps reach the network; ``None`` is the upload's 657 competition's setting. 658 """ 659 resp = self._http.request( 660 "POST", 661 f"/admin/authored-challenges/{challenge_id}/smoke", 662 params={"build_network": build_network} if build_network else None, 663 ) 664 _raise_for_status(resp) 665 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.
2873: build_network (online / offline) is whether this
run's build RUN steps reach the network; None is the upload's
competition's setting.
667 def resmoke_platform_faults( 668 self, *, competition_id: str = "", dry_run: bool = False 669 ) -> AdminTaskInfo: 670 """Re-smoke every upload #2029 mislabelled, once (the operator one-shot). 671 672 A dropped execute bit and a run-time ``pip install`` on a no-egress 673 smoke network recorded the author's ``unsolved`` / ``estate_failed``; 674 both are fixed. This enqueues the backfill, which selects those 675 uploads, re-runs each uncharged, and mails each author once that the 676 earlier failure was the platform's. ``dry_run`` reports the selection 677 and runs nothing. Returns the task. 678 """ 679 resp = self._http.request( 680 "POST", 681 "/admin/authored-challenges/resmoke-platform-faults", 682 params={"competition_id": competition_id, "dry_run": dry_run}, 683 ) 684 _raise_for_status(resp) 685 return AdminTaskInfo.model_validate(resp.json())
Re-smoke every upload #2029 mislabelled, once (the operator one-shot).
A dropped execute bit and a run-time pip install on a no-egress
smoke network recorded the author's unsolved / estate_failed;
both are fixed. This enqueues the backfill, which selects those
uploads, re-runs each uncharged, and mails each author once that the
earlier failure was the platform's. dry_run reports the selection
and runs nothing. Returns the task.
687 def strays(self) -> builtins.list[AuthoredStrayRow]: 688 """Every stored challenge id a competition may not hold (#2553): an 689 invitation-only event's upload in another event's list or map — what 690 :meth:`strip_strays` removes. Read-only; empty is clean. (Before an 691 upgrade the check is offline: ``ctfy-admin admin authored strays --db``.)""" 692 resp = self._http.request("GET", "/admin/authored-challenges/strays") 693 _raise_for_status(resp) 694 return [AuthoredStrayRow.model_validate(row) for row in resp.json()]
Every stored challenge id a competition may not hold (#2553): an
invitation-only event's upload in another event's list or map — what
strip_strays() removes. Read-only; empty is clean. (Before an
upgrade the check is offline: ctfy-admin admin authored strays --db.)
696 def strip_strays(self, *, apply: bool = False) -> AuthoredStripResult: 697 """Remove every stray (#2553) — a dry run unless ``apply``. Each 698 removal is recorded with the platform as actor and keeps its solves' 699 points; the boxes it left behind are reclaimed.""" 700 resp = self._http.request( 701 "POST", 702 "/admin/authored-challenges/strays/strip", 703 params={"apply": "true" if apply else "false"}, 704 ) 705 _raise_for_status(resp) 706 return AuthoredStripResult.model_validate(resp.json())
Remove every stray (#2553) — a dry run unless apply. Each
removal is recorded with the platform as actor and keeps its solves'
points; the boxes it left behind are reclaimed.
708 def publish( 709 self, challenge_id: str, *, exec_surface_reviewed: bool = False 710 ) -> AdminAuthoredChallengeRow: 711 """Write an approved upload into the deployment's catalog. 712 713 A separate verb from :meth:`review` on purpose: approving is a 714 person's verdict on somebody else's code, and it must not fail 715 because a corpus commit landed on the id in the days since. The 716 409 this raises carries every reason at once — the id is taken, 717 the row is not approved, the tree has no ``metadata.yaml`` — so a 718 caller fixes them in one pass rather than one press at a time. 719 720 ``exec_surface_reviewed`` confirms you read the files the tree 721 runs on a node or hands players to run (``executes`` on 722 :meth:`get`'s file list, plan P2.3). A tree with such files is 723 refused without it — once; the stamp then records who read it. 724 725 The row that comes back is **read back from the store**, so its 726 ``in_catalog`` is a fact about the disk after the write. 727 """ 728 resp = self._http.request( 729 "POST", 730 f"/admin/authored-challenges/{challenge_id}/publish", 731 # Only what was said: absent is the server's own default. 732 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 733 ) 734 _raise_for_status(resp) 735 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.
737 def release_to_catalog( 738 self, challenge_id: str, *, exec_surface_reviewed: bool = False 739 ) -> AdminAuthoredChallengeRow: 740 """Put a published upload in the **public catalog** (#1989). 741 742 ⚠️ **Publishing is not this.** A published upload is seen only in 743 the competitions that curate it — by their players once each 744 starts, their organisers, its authors and platform admins. This is 745 the separate step that lists it to everybody; who released it and 746 when lands on the row (``catalog_released_by`` / 747 ``catalog_released_at``), and a second call changes nothing. 748 749 A tree an automatic review released is refused (409, with reasons) 750 until a person has recorded a content verdict on it (:meth:`review`) 751 and a platform admin has read what it runs — ``exec_surface_reviewed`` 752 confirms the second on this call. 753 """ 754 resp = self._http.request( 755 "POST", 756 f"/admin/authored-challenges/{challenge_id}/catalog-release", 757 # Only what was said: absent is the server's own default. 758 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 759 ) 760 _raise_for_status(resp) 761 return AdminAuthoredChallengeRow.model_validate(resp.json())
Put a published upload in the public catalog (#1989).
⚠️ Publishing is not this. A published upload is seen only in
the competitions that curate it — by their players once each
starts, their organisers, its authors and platform admins. This is
the separate step that lists it to everybody; who released it and
when lands on the row (catalog_released_by /
catalog_released_at), and a second call changes nothing.
A tree an automatic review released is refused (409, with reasons)
until a person has recorded a content verdict on it (review())
and a platform admin has read what it runs — exec_surface_reviewed
confirms the second on this call.
763 def retire( 764 self, challenge_id: str, *, scores: AuthoredScoresChoice | None = None 765 ) -> AdminAuthoredChallengeRow: 766 """Stop offering an upload; its history is kept (#2760). 767 768 Platform admins only. It leaves every competition and is no longer 769 open to everyone; running environments run out on their own. A call 770 on an upload already retired changes nothing. 771 772 ``scores`` (#2991) decides the points already scored on it: ``kept`` 773 (and ``None``, the server's default) or ``removed`` — see 774 :meth:`points_at_stake` for what each would touch. 775 """ 776 resp = self._http.request( 777 "POST", 778 f"/admin/authored-challenges/{challenge_id}/retire", 779 params={"scores": scores} if scores else None, 780 ) 781 _raise_for_status(resp) 782 return AdminAuthoredChallengeRow.model_validate(resp.json())
Stop offering an upload; its history is kept (#2760).
Platform admins only. It leaves every competition and is no longer open to everyone; running environments run out on their own. A call on an upload already retired changes nothing.
scores (#2991) decides the points already scored on it: kept
(and None, the server's default) or removed — see
points_at_stake() for what each would touch.
784 def unretire(self, challenge_id: str) -> AdminAuthoredChallengeRow: 785 """Offer a retired upload again (#2760): back to approved with no 786 competition using it. A call on one that is not retired changes 787 nothing.""" 788 resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/unretire") 789 _raise_for_status(resp) 790 return AdminAuthoredChallengeRow.model_validate(resp.json())
Offer a retired upload again (#2760): back to approved with no competition using it. A call on one that is not retired changes nothing.
792 def other_uploads(self, challenge_id: str) -> AuthoredOtherUploads: 793 """The other uploads of this upload's challenge (#3110): same 794 competition, same title, same team or author — and the one approved 795 and smoked clean, suggested as the one to keep.""" 796 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/other-uploads") 797 _raise_for_status(resp) 798 return AuthoredOtherUploads.model_validate(resp.json())
The other uploads of this upload's challenge (#3110): same competition, same title, same team or author — and the one approved and smoked clean, suggested as the one to keep.
800 def merge( 801 self, challenge_id: str, *, into: str, reason: str, confirm: bool = False 802 ) -> AdminAuthoredChallengeDetail: 803 """Fold an upload into another upload of the same challenge (#3110): 804 recorded as ``merged`` with ``reason``, and taken out of every 805 competition's set (points scored there kept). Platform admins only. 806 Refused while anything is in play; ``confirm`` lifts only a running 807 event that would lose the challenge.""" 808 params: dict[str, str] = {"into": into, "reason": reason} 809 if confirm: 810 params["confirm"] = "true" 811 resp = self._http.request( 812 "POST", f"/admin/authored-challenges/{challenge_id}/merge", params=params 813 ) 814 _raise_for_status(resp) 815 return AdminAuthoredChallengeDetail.model_validate(resp.json())
Fold an upload into another upload of the same challenge (#3110):
recorded as merged with reason, and taken out of every
competition's set (points scored there kept). Platform admins only.
Refused while anything is in play; confirm lifts only a running
event that would lose the challenge.
817 def unmerge(self, challenge_id: str, *, reason: str = "") -> AdminAuthoredChallengeDetail: 818 """Undo a merge (#3110): the upload is its own challenge again; no 819 competition comes back by itself.""" 820 resp = self._http.request( 821 "POST", 822 f"/admin/authored-challenges/{challenge_id}/unmerge", 823 params={"reason": reason} if reason else None, 824 ) 825 _raise_for_status(resp) 826 return AdminAuthoredChallengeDetail.model_validate(resp.json())
Undo a merge (#3110): the upload is its own challenge again; no competition comes back by itself.
828 def unrelease_from_catalog(self, challenge_id: str) -> AdminAuthoredChallengeRow: 829 """Withdraw an upload's public-catalog release (#2759): no longer 830 open to everyone, only the competitions using it. 831 832 Platform admins only. Every competition use is kept — an upload no 833 competition uses is then seen by nobody — and environments running 834 outside a competition run out on their own. Recorded as an 835 ``unreleased`` lifecycle record; a call on an upload that is not 836 open to everyone changes nothing. 837 """ 838 resp = self._http.request( 839 "POST", f"/admin/authored-challenges/{challenge_id}/catalog-unrelease" 840 ) 841 _raise_for_status(resp) 842 return AdminAuthoredChallengeRow.model_validate(resp.json())
Withdraw an upload's public-catalog release (#2759): no longer open to everyone, only the competitions using it.
Platform admins only. Every competition use is kept — an upload no
competition uses is then seen by nobody — and environments running
outside a competition run out on their own. Recorded as an
unreleased lifecycle record; a call on an upload that is not
open to everyone changes nothing.
844 def withdraw_policy_release(self, challenge_id: str) -> AdminAuthoredChallengeRow: 845 """Withdraw the policy release an automatic review wrote (#3337): 846 the tree is no longer held to its event's dedicated pool, and starts 847 nowhere until a person approves it with ``exec_surface_reviewed`` 848 (:meth:`review`). Platform admins only; recorded as 849 ``policy_unreleased``; a call with no release changes nothing. 850 """ 851 resp = self._http.request( 852 "POST", f"/admin/authored-challenges/{challenge_id}/policy-unrelease" 853 ) 854 _raise_for_status(resp) 855 return AdminAuthoredChallengeRow.model_validate(resp.json())
Withdraw the policy release an automatic review wrote (#3337):
the tree is no longer held to its event's dedicated pool, and starts
nowhere until a person approves it with exec_surface_reviewed
(review()). Platform admins only; recorded as
policy_unreleased; a call with no release changes nothing.
857 def points_at_stake( 858 self, challenge_id: str, action: AuthoredFlowAction, competition_id: str = "" 859 ) -> AuthoredPointsAtStake: 860 """What removing an upload would decide about the points already 861 scored on it (#2991): for ``uncurate`` (in ``competition_id``, or its 862 own), ``retire`` or ``unpublish``, the ranked solves and teams at 863 stake and the effects of keeping and of removing them.""" 864 params: dict[str, str] = {"action": action} 865 if competition_id: 866 params["competition_id"] = competition_id 867 resp = self._http.request( 868 "GET", f"/admin/authored-challenges/{challenge_id}/points", params=params 869 ) 870 _raise_for_status(resp) 871 return AuthoredPointsAtStake.model_validate(resp.json())
873 def unpublish( 874 self, 875 challenge_id: str, 876 *, 877 reason: str, 878 scores: AuthoredScoresChoice | None = None, 879 ) -> AdminAuthoredChallengeRow: 880 """Take a published challenge back out of the deployment's catalog. 881 882 ``reason`` is required (``422`` with ``unpublish_needs_reason`` when 883 blank): the take-down is recorded — who, when, why — on the upload's 884 publication history (``AdminAuthoredChallengeDetail.lifecycle``, 885 #1965 H), which the authoring board reads. 886 887 :meth:`publish` had no inverse, so an upload that turned out to 888 leak its own answer stayed in front of every entrant and the only 889 recourse was a shell on the platform host. 890 891 It deletes the *publication*, never the row: the author's tree, 892 the reviewer's verdict and the smoke result all survive, and 893 publishing again is the same call it always was. The boxes already 894 running on it are stopped too (the route's ``stop_instances`` 895 defaults on and this call does not send it), so nobody new can 896 launch and nobody keeps playing it. It also leaves every competition 897 that uses it (#2740 D7); allowing it again adds it back to none. 898 899 Raises 409 when the deployment's catalog does not hold the id, 900 which is the mirror of publishing something already published. 901 902 ``scores`` (#2991): ``removed`` takes the points already scored on it 903 off the board; ``kept`` and ``None`` keep them. 904 """ 905 body: dict[str, str] = {"reason": reason} 906 if scores: 907 body["scores"] = scores 908 resp = self._http.request( 909 "POST", 910 f"/admin/authored-challenges/{challenge_id}/unpublish", 911 json=body, 912 ) 913 _raise_for_status(resp) 914 return AdminAuthoredChallengeRow.model_validate(resp.json())
Take a published challenge back out of the deployment's catalog.
reason is required (422 with unpublish_needs_reason when
blank): the take-down is recorded — who, when, why — on the upload's
publication history (AdminAuthoredChallengeDetail.lifecycle,
1965 H), which the authoring board reads.
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. The boxes already
running on it are stopped too (the route's stop_instances
defaults on and this call does not send it), so nobody new can
launch and nobody keeps playing it. It also leaves every competition
that uses it (#2740 D7); allowing it again adds it back to none.
Raises 409 when the deployment's catalog does not hold the id, which is the mirror of publishing something already published.
scores (#2991): removed takes the points already scored on it
off the board; kept and None keep them.
916 def curate( 917 self, challenge_id: str, competition_id: str = "", *, reason: str = "" 918 ) -> AdminAuthoredCurationRow: 919 """Put a published upload in front of this competition's entrants. 920 921 ⚠️ **The step after `publish`, and the two are not one act.** 922 Publishing writes the tree into this deployment's catalog; 923 curating adds its id to *one competition's* challenge set. Until 924 this existed the only way to take it was the competition edit 925 form, which re-sends the whole list — so two organisers working a 926 review queue dropped each other's additions. This appends. 927 928 Refused (409, with reasons) for a competition whose set comes 929 from a catalog bucket, and for an id this deployment has not 930 published: the second would put a challenge in front of entrants 931 that no node can start. 932 933 ``competition_id`` names another competition to curate it into — 934 the workshop → arena step — and every refusal is then asked of 935 that one. Empty is the upload's own competition. 936 937 ``warnings`` on the answer names what the target would refuse at its 938 own upload door and this verb does not (#1965): a static answer 939 curated into a competition reviewed automatically. 940 941 ``reason`` confirms the change for a competition that locks its 942 challenges while in play (#2041) — refused 943 (``curation_locked_needs_reason``) without one, and recorded with it. 944 """ 945 resp = self._http.request( 946 "POST", 947 f"/admin/authored-challenges/{challenge_id}/curation", 948 params=_curation_params(competition_id, reason), 949 ) 950 _raise_for_status(resp) 951 return AdminAuthoredCurationRow.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.
warnings on the answer names what the target would refuse at its
own upload door and this verb does not (#1965): a static answer
curated into a competition reviewed automatically.
reason confirms the change for a competition that locks its
challenges while in play (#2041) — refused
(curation_locked_needs_reason) without one, and recorded with it.
953 def uncurate( 954 self, 955 challenge_id: str, 956 competition_id: str = "", 957 *, 958 reason: str = "", 959 scores: AuthoredScoresChoice | None = None, 960 ) -> AdminAuthoredCurationRow: 961 """Take an upload back out of this competition's challenge set. 962 963 Carries neither of :meth:`curate`'s refusals: removing an id from 964 a set that should not hold it is the repair, so gating it on the 965 same conditions would keep the broken state. ``competition_id`` 966 names which competition; empty is the upload's own. ``reason`` is 967 :meth:`curate`'s, for a locked competition in play (#2041). 968 969 ``scores`` (#2991): ``kept`` keeps the points already scored there; 970 ``removed`` and ``None`` (the removal as it always was) do not. 971 """ 972 params = _curation_params(competition_id, reason) or {} 973 if scores: 974 params["scores"] = scores 975 resp = self._http.request( 976 "DELETE", 977 f"/admin/authored-challenges/{challenge_id}/curation", 978 params=params or None, 979 ) 980 _raise_for_status(resp) 981 return AdminAuthoredCurationRow.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. reason is
curate()'s, for a locked competition in play (#2041).
scores (#2991): kept keeps the points already scored there;
removed and None (the removal as it always was) do not.
983 def bulk_curate( 984 self, 985 competition_id: str, 986 target_competition_ids: builtins.list[str], 987 *, 988 challenge_ids: builtins.list[str] | None = None, 989 lock_versions: bool = True, 990 dry_run: bool = False, 991 reason: str = "", 992 ) -> BulkCurationResponse: 993 """Add ``competition_id``'s approved uploads to each target (#2120). 994 995 Each (upload, target) pair is :meth:`curate`'s press, with its 996 refusals, and gets one ``outcome``; ``challenge_ids`` narrows the 997 uploads. ``lock_versions`` turns each target's version lock on after. 998 ``reason`` confirms changes to a target locked in play (#2041). 999 ``dry_run=True`` is the preview. 1000 """ 1001 resp = self._http.request( 1002 "POST", 1003 f"/admin/competitions/{competition_id}/authored-challenges/bulk-curation", 1004 json={ 1005 "target_competition_ids": list(target_competition_ids), 1006 "challenge_ids": challenge_ids, 1007 "lock_versions": lock_versions, 1008 "dry_run": dry_run, 1009 "confirm": bool(reason), 1010 "reason": reason, 1011 }, 1012 ) 1013 _raise_for_status(resp) 1014 return BulkCurationResponse.model_validate(resp.json())
Add competition_id's approved uploads to each target (#2120).
Each (upload, target) pair is curate()'s press, with its
refusals, and gets one outcome; challenge_ids narrows the
uploads. lock_versions turns each target's version lock on after.
reason confirms changes to a target locked in play (#2041).
dry_run=True is the preview.
1016 def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]: 1017 """Every revision of one upload, newest first — the proposal, if 1018 any, and the history of those applied (plan P3.1).""" 1019 resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions") 1020 _raise_for_status(resp) 1021 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).
1023 def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff: 1024 """A proposed revision against the live tree — what a reviewer reads. 1025 1026 ⚠️ A changed file with an empty ``diff`` says why in ``omitted`` 1027 (``binary``, ``too_large``, ``over_budget``); read such a file 1028 whole with :meth:`read_revision_file`. ``needs_exec_review`` says 1029 whether :meth:`apply_revision` will ask you to confirm the 1030 execution-surface review. 1031 """ 1032 resp = self._http.request( 1033 "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff" 1034 ) 1035 _raise_for_status(resp) 1036 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.
1038 def read_revision_file( 1039 self, challenge_id: str, revision_id: str, path: str 1040 ) -> AuthoredChallengeFileBody: 1041 """One file of a revision, whole — attacker-authored text written 1042 to be run, like :meth:`read_file`'s.""" 1043 resp = self._http.request( 1044 "POST", 1045 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file", 1046 json={"path": path}, 1047 ) 1048 _raise_for_status(resp) 1049 return AuthoredChallengeFileBody.model_validate(resp.json())
One file of a revision, whole — attacker-authored text written
to be run, like read_file()'s.
1051 def review_revision( 1052 self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = "" 1053 ) -> AdminAuthoredRevisionRow: 1054 """Record a verdict on a proposed revision. ``rejected`` requires a 1055 note and ``pending`` withdraws a verdict, as for an upload. An 1056 applied revision is history and is refused (409).""" 1057 resp = self._http.request( 1058 "POST", 1059 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review", 1060 json={"status": status, "note": note}, 1061 ) 1062 _raise_for_status(resp) 1063 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).
1065 def apply_revision( 1066 self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False 1067 ) -> AdminAuthoredChallengeRow: 1068 """Make an approved revision the upload's live version (plan P3.1). 1069 1070 A published upload's directory is replaced whole — boxes already 1071 running keep the bytes they started with — and the catalog is 1072 reloaded. Nodes that hold the upload keep the previous tree until 1073 :meth:`push` sends the new one. 1074 1075 ``exec_surface_reviewed`` confirms you read the revision's files 1076 marked ``executes``: a published upload whose revision has any is 1077 refused without it, every time — the bytes are new. The 409 carries 1078 every reason at once. 1079 """ 1080 resp = self._http.request( 1081 "POST", 1082 f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply", 1083 params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None, 1084 ) 1085 _raise_for_status(resp) 1086 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.