Repository navigation
Protect grader data from student code and load data.json in images - #27
Draft
eliotwrobson wants to merge 8 commits into
Draft
eliotwrobson wants to merge 8 commits into
eliotwrobson wants to merge 8 commits into
Conversation
query_setup asked the sandbox for dill-encoded values after student code had run, so student code could forge the data the grader then unpickled. Encode setup variables once, after setup code and before student code, and wait for the grader's ack before starting student code. query_setup now reads only that stored snapshot, so functions and other dill-only values keep working. Values dill can't encode raise a clear TypeError. Also add plot-serializer to the full image, and correct the docs to say plot grading needs MatplotlibSerializer rather than a plain Figure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PrairieLearn writes /grade/data/data.json, but the data_json fixture reads data.json next to the test file and run.sh never copied it there, so tests using data_json failed with a NoneType error. Copy it in run.sh. data.json can hold correct_answers, so make both copies root-owned with owner-only modes before student code runs, and stop grading if that fails. Root ownership matters because the student uid can match the host owner of mounted files, and owner-only modes matter because student code keeps root's supplementary groups. The fixture now raises a clear error for a data.json it can't parse or read, instead of treating it like a missing file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The data_json fixture now reads $JOB_DIR/data/data.json when run.sh sets JOB_DIR, and otherwise falls back to data.json next to the test file. This removes the copy from run.sh, so there's only one file holding answers to protect, and it stays out of a directory student code can read. The image's package and run.sh now depend on each other: this run.sh with package 1.1.0 finds no data.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the fixture's JOB_DIR lookup with --data-json-path, next to --worker-username, so the plugin no longer depends on the environment or the job directory layout. run.sh now takes the job directory as an optional argument and no longer exports JOB_DIR or MERGE_DIR, which student subprocesses would inherit. The option has to use the --data-json-path=VALUE form: pytest's first pass over the arguments would treat a separate value as a test path, which moves rootdir and loses the results file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Encoding every setup variable at startup slows every sandbox when setup code holds large values. Values JSON can represent exactly now stay in the sandbox and are sent as JSON only when queried. Values it can't, such as functions and tuples, are still dill-encoded once before student code starts, and only those pre-student payloads are ever unpickled. Function payloads leave out globals the function doesn't use. Student code could change a JSON value before it's queried, but a forged response can't run code in the grader. A value that can't be serialized when queried raises a clear TypeError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on-ae9f02 # Conflicts: # docker/run.sh # quick_start.md # src/pytest_prairielearn_grader/plugin.py
Its first test failed in CI on Python 3.11 with "No response from initialization with timeout 1.0" while the other tests passed. The first sandbox imports matplotlib and plot_serializer, and those imports took about 8s locally without cached bytecode, against about 0.9s with it. The failure didn't reproduce locally, so this cause is likely but not confirmed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 5, 2026
eliotwrobson
added a commit
that referenced
this pull request
Nov 5, 2026
The runner compared message types to raw strings, so a typo would silently match nothing. They now use a MessageType enum, as does the "failure" status the server sends for its own errors. The grader never handled that failure response, so it reached tests as a KeyError, and later queries failed with socket errors. Queries now raise a RuntimeError with the server's message, and later queries raise the same error. query_setup_raw isn't changed, to keep PR #27, which rewrites it, merging cleanly. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…on-ae9f02 # Conflicts: # src/pytest_prairielearn_grader/fixture.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
query_setupasked the sandbox for dill-encoded values after student code had run. Setup and student code share a subprocess, so student code could forge the data that the grader then unpickled, and the grader process appears to run as root. Experiments confirmed student code can reach the runner through traceback frames with no imports, and that an ordinary global assignment can end up in a setup function's pickle and run when it's loaded.The grader now only unpickles data captured before student code starts:
query_setupreads these from the stored copy. Function payloads leave out globals the function doesn't use.TypeErrornaming the variable. Other setup variables still work.This PR also adds
plot-serializer==0.2.3to the full image, and corrects the docs to say plot grading needsMatplotlibSerializerrather than a plainFigure.Loading
data.jsonin the grader imagesPrairieLearn writes
/grade/data/data.json, but thedata_jsonfixture only readdata.jsonnext to the test file, and nothing put it there. Any test usingdata_json["params"]failed with'NoneType' object is not subscriptable, so correct submissions scored 0.run.shnow passes--data-json-path=/grade/data/data.jsonto pytest. Without that option, or if that file doesn't exist, the fixture readsdata.jsonnext to the test file, which local runs and the scenario tests rely on.data.jsoncan containcorrect_answers, sorun.shmakes the data directory and file root-owned with owner-only modes before student code runs. If that fails, grading stops with a failure result instead of running student code. Root ownership is needed because the student uid (1000) can match the host owner of mounted files. Owner-only modes are needed because student code keeps root's supplementary groups.Nonewhendata.jsonis missing, but now raises a clear error when it can't be parsed or read.run.shtakes the job directory as an optional argument, defaulting to/grade, sotests/test_run_script.pycan run the real script. It no longer exportsJOB_DIRorMERGE_DIR.Release together: the image's
run.shand package now depend on each other. Thisrun.shwith package 1.1.0 fails on the unknown option and writes a failure result for every submission.Behaviour changes
main.query_setupnow takesquery_timeout, defaulting to 1 second, because JSON values travel over the socket.Known gap
The dill snapshot protects against code in the same subprocess. Each test starts a new subprocess under the same
aguser, and cleanup only terminates that one process. From reading the code, a background process left by student code in an earlier test could tamper with a later subprocess before its snapshot is taken. This wasn't tried, and closing it would need per-test process cleanup or separate users.Testing
Run on Python 3.14:
uv run pytest tests -p no:prairielearn-grader: 104 passed on the final code, with one earliertest_initialization_timeoutfailure described belowmypy src/: cleanruff check: two newPLC0415lazy-import findings injson_utils.py, the same patternto_jsonuses; otherwise no change frommaintest_query_setup_snapshotscenario: student code forges malicious pickles and JSON responses, overwrites setup values and rebinds a global. An earlier version of it failed 4 of 5 tests onmain.ag, the new scenario scored 5/5 andtest_matplotlib_plotsscored 12/12.run.sh, a/gradelayout copied onto the container's native filesystem: the convertedrandomFunctionNamequestion scored 1.0 for a correct submission and 0.6 for a wrongquestvalue, with files starting out owned by root or by uid 1000.aggotPermission deniedon/grade/data/data.jsonand on listing/grade/data, while the grader still loaded the file.agcould read the file), which appears to be specific to that mount. A Linux host bind mount, which is what production uses, was not tested.test_matplotlib_plotsfailed on the first run in fresh local environments, with and without this change, while matplotlib was building its font cache. It looks like a first-run timeout, but that isn't confirmed.test_initialization_timeoutfailed once in an earlier full suite run. Its failure details weren't saved. It then passed 8 times alone, and 5 further full runs each on this branch andmainhad no failures, so the cause is unconfirmed and it may be a rare timing issue.Follow-ups
agcan still read other grading material:/grade/run/test_student.py, and everything from/grade/testssuch assetup_code.py. It can also reachparamsin sandbox memory throughgc, so values inparamsaren't hidden from student code.drop_privilegesdoesn't callsetgroups, so student code keeps root's supplementary groups (0, 1, 2, 3, 4, 6, 10, 11, 20, 26, 27).agalso owns the moved test files and could likely modify them, includingsetup_code.py, which is read again for each sandbox. This comes from reading the code and wasn't tried.Figurefrom student code is still unsupported. From reading the code, it fails in the handler'sjson.dumpsand closes the connection, which likely gives the author an obscure error.🤖 Generated with Claude Code