Skip to content

Commit ce273e1

Browse files
m-messerclaude
andcommitted
Merge branch 'main' into feature/file_upload
Reconcile the file-upload feature with main's security refactor, GCS plot backend, and nsjail sandboxing. - evaluation.py / preview.py: use the shared check_code_safety() from the new security.py; evaluation_function runs it as a pre-execution gate on the resolved student code (post _resolve_submission). - security.py: keep `open` and `pathlib` allowed (main's version blocked them) — student code needs them to read files from params["files"] / the response payload; runtime _safe_open still blocks writes into the files dir. security_test.py updated to match. - Dockerfile: take main's python:3.12 base (bundles shimmy + nsjail). - CLAUDE.md: merged pipeline/env docs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VSE4TC3eTVreuQhADdtDaj
2 parents 0aed5f1 + 645b04a commit ce273e1

13 files changed

Lines changed: 506 additions & 70 deletions

File tree

.github/workflows/production-deploy.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ jobs:
4141
uses: lambda-feedback/evaluation-function-workflows/.github/workflows/deploy.yml@main
4242
with:
4343
template-repository-name: 'lambda-feedback/evaluation-function-boilerplate-python'
44+
# gcp -> Cloud Run gen2 (see gcp_deploy.yml). gen2 gives full-Linux
45+
# compatibility so shimmy's nsjail sandbox (SANDBOX_ENABLED=true in the
46+
# Dockerfile) can create user namespaces -- AWS Lambda cannot.
47+
build-platforms: "gcp"
4448
environment: "production"
4549
version-bump: ${{ inputs.version-bump }}
4650
branch: ${{ inputs.branch }}

.github/workflows/staging-deploy.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ jobs:
5959
uses: lambda-feedback/evaluation-function-workflows/.github/workflows/deploy.yml@main
6060
with:
6161
template-repository-name: "lambda-feedback/evaluation-function-boilerplate-python"
62-
build-platforms: "aws"
62+
# gcp -> Cloud Run gen2 (see gcp_deploy.yml). gen2 gives full-Linux
63+
# compatibility so shimmy's nsjail sandbox (SANDBOX_ENABLED=true in the
64+
# Dockerfile) can create user namespaces -- AWS Lambda cannot.
65+
build-platforms: "gcp"
6366
environment: "staging"
6467
lfs: false
6568
secrets:

CLAUDE.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ All source lives in `evaluation_function/`:
99
| File | Role |
1010
|------|------|
1111
| `main.py` | IPC server entry point; registers `evaluation_function` and `preview_function` with lf_toolkit |
12-
| `evaluation.py` | Core evaluation pipeline: security check → subprocess execution → output comparison → S3 plot upload → structured feedback |
12+
| `evaluation.py` | Core evaluation pipeline: security check → subprocess execution → output comparison → plot upload (GCS/S3 via lf_toolkit) → structured feedback |
1313
| `preview.py` | AST-based pre-execution security validator (`_SecurityVisitor`) |
1414
| `s3_files.py` | Downloads `params["files"]` objects from S3 into the per-request working directory |
1515
| `dev.py` | CLI wrapper for local manual testing |
@@ -22,7 +22,7 @@ All source lives in `evaluation_function/`:
2222
- **`demo`**: execute code with no stdin; return stdout/plots as `output` feedback (no pass/fail)
2323
- **`io_test`**: for each test in `params["tests"]`, execute with `test["input"]` as stdin and compare stdout against `test["expected_output"]`; upload matplotlib plots on pass or fail
2424
- **`unit_test`**: append `params["test_code"]` + unit-runner harness to student code; execute once; parse JSON results; supports plain `test_*` functions, `unittest.TestCase` subclasses, and Hypothesis-based tests
25-
4. Upload any captured matplotlib figures to S3 (`_UPLOAD_FOLDER = "evaluatePython"`)
25+
4. Upload any captured matplotlib figures via `lf_toolkit` `upload_image` (`_UPLOAD_FOLDER = "evaluatePython"`); backend is GCS or S3 per `IMAGE_UPLOAD_BACKEND`
2626
5. Return a `Result` with feedback tags: `pass`, `fail`, `hidden_fail`, `error`, `output`, `summary`
2727

2828
### Request shape
@@ -156,16 +156,19 @@ docker build -t evaluatepython .
156156
# Cross-platform (CI uses linux/x86_64):
157157
docker build --platform=linux/x86_64 .
158158

159-
# Run the server locally (port 8080)
160-
docker run -it --rm -p 8080:8080 evaluatepython
159+
# Run the server locally (port 8080).
160+
# --privileged is required: the image enables shimmy's nsjail sandbox
161+
# (SANDBOX_ENABLED=true), and nsjail needs it to create namespaces.
162+
docker run -it --rm --privileged -p 8080:8080 evaluatepython
161163
```
162164

163165
## Tests
164166

165-
Two test files, run with `pytest`:
167+
Three test files, run with `pytest`:
166168

167-
- `evaluation_function/evaluation_test.py` — integration tests covering: all modes (demo, io_test, unit_test), all pass, partial fail, hidden test failure, runtime error, matplotlib plot capture, Hypothesis support
169+
- `evaluation_function/evaluation_test.py` — integration tests covering: all modes (demo, io_test, unit_test), all pass, partial fail, hidden test failure, runtime error, matplotlib plot capture, Hypothesis support, the pre-execution security gate
168170
- `evaluation_function/preview_test.py` — unit tests covering: valid Python, syntax errors, dangerous imports, dangerous builtins, dunder access
171+
- `evaluation_function/security_test.py` — unit tests for `check_code_safety` (the shared AST blocklist used by both `preview_function` and `evaluation_function`)
169172

170173
CI runs on Python 3.12 and uploads JUnit XML results (`.github/workflows/test-lint.yml`).
171174

@@ -177,14 +180,20 @@ CI runs on Python 3.12 and uploads JUnit XML results (`.github/workflows/test-li
177180
| `MPLBACKEND` | `Agg` | Set at subprocess runtime to suppress GUI |
178181
| `FUNCTION_COMMAND` | `python` | lf_toolkit runner |
179182
| `FUNCTION_ARGS` | `-m,evaluation_function.main` | lf_toolkit runner |
180-
| `FUNCTION_RPC_TRANSPORT` | `ipc` | lf_toolkit transport |
183+
| `FUNCTION_RPC_TRANSPORT` | `stdio` | shimmy↔worker transport (stdio so it survives the sandbox mount namespace) |
181184
| `LOG_LEVEL` | `debug` | Logging verbosity |
182-
| `AWS_*` / boto3 credentials | Runtime env | Required for S3 plot uploads. Not needed for `params["files"]` downloads — those are fetched via plain HTTPS GET from a pre-signed/public URL |
185+
| `IMAGE_UPLOAD_BACKEND` | `gcs` | Plot upload backend in lf_toolkit (`gcs` set in Dockerfile; override to `s3` on the service to use AWS) |
186+
| `GCS_BUCKET` | Runtime env | Target bucket for matplotlib plot uploads; set per-environment on the Cloud Run service. Auth is via the runtime service account (ADC) — no keys |
187+
| `AWS_*` / `S3_BUCKET_URI` | Runtime env | Only for the legacy S3 plot-upload backend (`IMAGE_UPLOAD_BACKEND=s3`). Not needed for `params["files"]` / response-payload file downloads — those are plain HTTPS GETs from a pre-signed/public URL |
188+
| `SANDBOX_ENABLED` | `true` | Wrap the worker in shimmy's nsjail sandbox (needs `--privileged` at run time) |
189+
| `SANDBOX_SECCOMP` | `true` | nsjail seccomp syscall filter |
190+
| `SANDBOX_RO_BINDS` | `/usr:/lib:/lib64:/bin:/sbin:/etc:/app` | Read-only bind mounts visible inside the jail |
191+
| `SANDBOX_TMPFS` | `/tmp` | Writable tmpfs inside the jail (student scripts, plot dirs, MPLCONFIGDIR) |
183192

184193
Dependencies managed via Poetry; `.venv` is created in-project (`poetry.toml`).
185194

186195
## Deployment
187196

188197
- Push to `main` triggers GitHub Actions (`.github/workflows/`) which builds and deploys to Lambda Feedback automatically
189198
- The function name is declared in `config.json` as `EvaluationFunctionName: "evaluatePython"` (lowerCamelCase)
190-
- The base Docker image is `ghcr.io/lambda-feedback/evaluation-function-base/python:test-sandbox-3.12`
199+
- The base Docker image is `ghcr.io/lambda-feedback/evaluation-function-base/python:3.12` (bundles shimmy + nsjail; sandboxing is enabled via the `SANDBOX_*` env vars in the Dockerfile, not by the base tag)

Dockerfile

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM ghcr.io/lambda-feedback/evaluation-function-base/python:latest AS builder
1+
FROM ghcr.io/lambda-feedback/evaluation-function-base/python:3.12 AS builder
22

33
RUN pip install poetry==1.8.3
44

@@ -12,7 +12,7 @@ COPY pyproject.toml poetry.lock ./
1212
RUN --mount=type=cache,target=$POETRY_CACHE_DIR \
1313
poetry install --without dev --no-root
1414

15-
FROM ghcr.io/lambda-feedback/evaluation-function-base/python:latest
15+
FROM ghcr.io/lambda-feedback/evaluation-function-base/python:3.12
1616

1717
ENV VIRTUAL_ENV=/app/.venv \
1818
PATH="/app/.venv/bin:$PATH"
@@ -25,13 +25,60 @@ RUN python -m compileall -q .
2525
# Copy the evaluation function to the app directory
2626
COPY evaluation_function ./evaluation_function
2727

28-
# Command to start the evaluation function with
29-
ENV FUNCTION_COMMAND="python"
28+
# How lf_toolkit's runner launches the worker.
29+
ENV FUNCTION_COMMAND="python" \
30+
FUNCTION_ARGS="-m,evaluation_function.main"
3031

31-
# Args to start the evaluation function with
32-
ENV FUNCTION_ARGS="-m,evaluation_function.main"
32+
# RPC transport: stdio, not ipc. The sandboxed worker runs in an nsjail mount
33+
# namespace where the /tmp/eval.sock IPC rendezvous is fragile; stdio sidesteps
34+
# it. lf_toolkit logs to stderr, so stdout stays clean for the RPC framing.
35+
ENV FUNCTION_RPC_TRANSPORT="stdio"
3336

34-
# The transport to use for the RPC server
35-
ENV FUNCTION_RPC_TRANSPORT="ipc"
37+
# --- Sandboxed execution of untrusted student code (shimmy + nsjail) ---
38+
# Always on. shimmy wraps the worker -- and every `python` subprocess it spawns
39+
# per submission -- in nsjail: run as nobody, a read-only bind-mounted rootfs,
40+
# namespace isolation. Defence in depth on top of the AST gate in
41+
# evaluation_function/security.py, which rejects unsafe imports/builtins before
42+
# any code runs.
43+
#
44+
# Requires a base image that ships /usr/sbin/nsjail AND a shimmy build with the
45+
# sandbox fixes (--keep_env, PATH resolution of FUNCTION_COMMAND, --cwd fallback,
46+
# kafel seccomp, namespace toggles). With stock shimmy the worker fails to start.
47+
#
48+
# Run-time: the container must run --privileged (or --cap-add SYS_ADMIN) -- nsjail
49+
# needs CAP_SYS_ADMIN for unshare(CLONE_NEWNS). Running as uid 0, nsjail's "auto"
50+
# userns handling then drops CLONE_NEWUSER (a nested userns' unprivileged gid_map
51+
# write fails); --user still drops the worker to nobody. Network stays up so
52+
# matplotlib plots can be uploaded to object storage (see below).
53+
#
54+
# SANDBOX_RO_BINDS / _RW_BINDS: shimmy splits these env vars on COMMA. Bind "/"
55+
# read-only (whole rootfs -- arch-independent, where an explicit list would need
56+
# /lib64 only on x86_64, and a missing bind source is fatal to nsjail), then
57+
# re-mount /tmp read-write. Not SANDBOX_TMPFS: nsjail's tmpfs defaults to 4 MiB,
58+
# too small for matplotlib's font cache + plot output.
59+
#
60+
# SANDBOX_DISABLE_CLONE_NEWPID: a nested PID namespace breaks worker thread
61+
# creation on some hosts ("pthread_create ... Invalid argument"); the mount and
62+
# user namespaces still isolate the filesystem and privileges.
63+
#
64+
# Network stays up so matplotlib plots (see evaluation.py::_upload_plots) can be
65+
# pushed to object storage. On GCP we use lf_toolkit's GCS backend
66+
# (IMAGE_UPLOAD_BACKEND=gcs): the worker authenticates with Application Default
67+
# Credentials via the Cloud Run runtime service account -- no static keys -- and
68+
# needs GCS_BUCKET set on the service (staging/prod differ). To fall back to S3,
69+
# override IMAGE_UPLOAD_BACKEND=s3 on the service and set S3_BUCKET_URI / AWS_*.
70+
#
71+
# No seccomp (nsjail has no built-in default policy; the fixed shimmy takes a
72+
# kafel policy via SANDBOX_SECCOMP_STRING / _POLICY_FILE if wanted) and no
73+
# rlimits (the RPC worker is long-lived and shared; per-run limits are the
74+
# _TIMEOUT in evaluation.py).
75+
ENV SANDBOX_ENABLED="true" \
76+
SANDBOX_RO_BINDS="/" \
77+
SANDBOX_RW_BINDS="/tmp" \
78+
SANDBOX_DISABLE_CLONE_NEWPID="true"
79+
80+
# Plot upload backend (lf_toolkit). GCS_BUCKET is supplied per-environment on the
81+
# Cloud Run service.
82+
ENV IMAGE_UPLOAD_BACKEND="gcs"
3683

3784
ENV LOG_LEVEL="debug"

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ Push to `main` triggers GitHub Actions which automatically builds and deploys to
1212
### Run the Docker Image
1313

1414
```bash
15-
docker run -it --rm -p 8080:8080 ghcr.io/lambda-feedback/evaluatepython:latest
15+
docker run -it --rm --privileged -p 8080:8080 ghcr.io/lambda-feedback/evaluatepython:latest
1616
```
1717

1818
The image includes [Shimmy](https://github.com/lambda-feedback/shimmy), which listens for HTTP requests on port 8080 and forwards them to the evaluation function.
1919

20+
`--privileged` (or `--cap-add SYS_ADMIN`) is required: the image runs student code inside Shimmy's [nsjail](https://github.com/google/nsjail) sandbox (`SANDBOX_ENABLED=true`), and nsjail needs those privileges to create its namespaces. The sandbox runs the worker as `nobody` with a minimal bind-mounted filesystem and seccomp filtering; untrusted imports/builtins are additionally rejected before execution by the AST check in `evaluation_function/security.py`.
21+
2022
### Evaluation Modes
2123

2224
The function supports three modes, set via `params.mode`.
@@ -80,10 +82,12 @@ Add `"pep8_feedback": true` to any mode to append a style check to the feedback.
8082
```
8183
evaluation_function/main.py # IPC server entry point
8284
evaluation_function/evaluation.py # core evaluation pipeline (all three modes)
83-
evaluation_function/preview.py # AST-based security validator
85+
evaluation_function/security.py # shared AST blocklist (check_code_safety)
86+
evaluation_function/preview.py # pre-submission validator (syntax + security)
8487
evaluation_function/dev.py # CLI wrapper for local testing
8588
evaluation_function/evaluation_test.py # integration tests
8689
evaluation_function/preview_test.py # preview/security tests
90+
evaluation_function/security_test.py # check_code_safety unit tests
8791
config.json # deployment configuration
8892
```
8993

@@ -144,9 +148,11 @@ docker build --platform=linux/x86_64 -t evaluatepython .
144148
### Running the Docker Image
145149

146150
```bash
147-
docker run -it --rm -p 8080:8080 evaluatepython
151+
docker run -it --rm --privileged -p 8080:8080 evaluatepython
148152
```
149153

154+
`--privileged` is required for the nsjail sandbox (see [Run the Docker Image](#run-the-docker-image)). To run without it for local debugging, disable the sandbox: add `-e SANDBOX_ENABLED=false`.
155+
150156
## Deployment to Lambda Feedback
151157

152158
The function name is declared in [`config.json`](config.json) as `"evaluatePython"` (lowerCamelCase). Pushing to `main` triggers automated deployment via GitHub Actions.

docs/dev.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
- **`evaluation_function`** — run and grade student code
88
- **`preview_function`** — static AST-based security check (called before execution)
99

10+
Both share the AST blocklist in `security.py` (`check_code_safety`). `preview_function`
11+
runs it pre-submission; `evaluation_function` also runs it as a hard gate — if the
12+
submission trips the blocklist it is **not executed** and an `error` item is returned.
13+
At the container level, shimmy additionally wraps execution in an nsjail sandbox
14+
(`SANDBOX_*` env in the `Dockerfile`).
15+
1016
---
1117

1218
## `evaluation_function`
@@ -173,7 +179,9 @@ The check runs in-process via `pycodestyle.Checker` with `max_line_length=200` (
173179

174180
## `preview_function`
175181

176-
Called before evaluation. Parses the student code as an AST and checks for security violations.
182+
Called before evaluation. Parses the student code as an AST and checks for security
183+
violations via `security.check_code_safety`. The same check gates `evaluation_function`,
184+
so the blocklist below applies to both entry points.
177185

178186
### Blocked constructs
179187

evaluation_function/evaluation.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from lf_toolkit.evaluation.image_upload import upload_image, ImageUploadError
1515

1616
from .s3_files import download_files
17+
from .security import check_code_safety
1718

1819
_TIMEOUT = 25
1920
_UPLOAD_FOLDER = "evaluatePython"
@@ -379,6 +380,14 @@ def evaluation_function(response: Any, answer: Any, params: Params) -> Result:
379380

380381
code, file_specs = _resolve_submission(response, params)
381382

383+
violations = check_code_safety(code)
384+
if violations:
385+
result.add_feedback(
386+
"error",
387+
"Unsafe code detected -- not executed:\n" + "\n".join(f"- {v}" for v in violations),
388+
)
389+
return result
390+
382391
files_dir = None
383392
try:
384393
file_warnings: list[str] = []

evaluation_function/evaluation_test.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -593,4 +593,40 @@ def test_pep8_appended_to_io_test_result(self):
593593
params = {**_params(_test("5\n", "25\n")), "pep8_feedback": True}
594594
result = evaluation_function(_SQUARE_CODE, None, params).to_dict()
595595
self.assertTrue(result["is_correct"])
596-
self.assertIn("No style issues found", result["feedback"])
596+
self.assertIn("No style issues found", result["feedback"])
597+
598+
599+
class TestSecurityGate(unittest.TestCase):
600+
601+
def test_blocked_import_not_executed(self):
602+
result = evaluation_function(
603+
"import os\nprint(os.getcwd())", None, {"mode": "demo"}
604+
).to_dict()
605+
self.assertFalse(result["is_correct"])
606+
self.assertIn("Unsafe code detected", result["feedback"])
607+
self.assertIn("os", result["feedback"])
608+
609+
def test_blocked_builtin_not_executed(self):
610+
result = evaluation_function(
611+
"eval('1 + 1')", None, _params(_test("", ""))
612+
).to_dict()
613+
self.assertIn("Unsafe code detected", result["feedback"])
614+
self.assertNotIn("tests passed", result["feedback"])
615+
616+
def test_dunder_access_blocked_in_unit_test(self):
617+
result = evaluation_function(
618+
"().__class__.__bases__", None, _unit_params(_SQUARE_TESTS)
619+
).to_dict()
620+
self.assertIn("Unsafe code detected", result["feedback"])
621+
622+
def test_safe_stdlib_import_still_runs(self):
623+
result = evaluation_function(
624+
"import math\nprint(math.sqrt(16))", None, {"mode": "demo"}
625+
).to_dict()
626+
self.assertIn("4.0", result["feedback"])
627+
self.assertNotIn("Unsafe", result["feedback"])
628+
629+
def test_syntax_error_passes_gate_and_surfaces_downstream(self):
630+
result = evaluation_function("def f(:\n", None, {"mode": "demo"}).to_dict()
631+
self.assertNotIn("Unsafe code detected", result["feedback"])
632+
self.assertIn("SyntaxError", result["feedback"])

evaluation_function/preview.py

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,55 +2,19 @@
22
from typing import Any
33
from lf_toolkit.preview import Result, Params, Preview
44

5-
_BLOCKED_MODULES = {
6-
"os", "sys", "subprocess", "socket", "urllib", "http",
7-
"requests", "shutil", "ftplib", "smtplib",
8-
"ctypes", "multiprocessing", "threading", "importlib",
9-
"pickle", "builtins",
10-
}
11-
12-
_BLOCKED_BUILTINS = {"exec", "eval", "compile", "__import__"}
13-
14-
15-
class _SecurityVisitor(ast.NodeVisitor):
16-
def __init__(self):
17-
self.violations: list[str] = []
18-
19-
def visit_Import(self, node):
20-
for alias in node.names:
21-
root = alias.name.split(".")[0]
22-
if root in _BLOCKED_MODULES:
23-
self.violations.append(f"import of '{root}' is not allowed")
24-
self.generic_visit(node)
25-
26-
def visit_ImportFrom(self, node):
27-
if node.module:
28-
root = node.module.split(".")[0]
29-
if root in _BLOCKED_MODULES:
30-
self.violations.append(f"import of '{root}' is not allowed")
31-
self.generic_visit(node)
32-
33-
def visit_Call(self, node):
34-
if isinstance(node.func, ast.Name) and node.func.id in _BLOCKED_BUILTINS:
35-
self.violations.append(f"use of '{node.func.id}()' is not allowed")
36-
self.generic_visit(node)
37-
38-
def visit_Attribute(self, node):
39-
if node.attr.startswith("__") and node.attr.endswith("__"):
40-
self.violations.append(f"access to '{node.attr}' is not allowed")
41-
self.generic_visit(node)
5+
from .security import check_code_safety
426

437

448
def preview_function(response: Any, params: Params) -> Result:
9+
code = str(response)
4510
try:
46-
tree = ast.parse(str(response))
11+
ast.parse(code)
4712
except SyntaxError as e:
4813
return Result(preview=Preview(feedback=f"SyntaxError: {e.msg} (line {e.lineno})"))
4914

50-
visitor = _SecurityVisitor()
51-
visitor.visit(tree)
52-
if visitor.violations:
53-
lines = "\n".join(f"- {v}" for v in visitor.violations)
15+
violations = check_code_safety(code)
16+
if violations:
17+
lines = "\n".join(f"- {v}" for v in violations)
5418
return Result(preview=Preview(feedback=f"Unsafe code detected:\n{lines}"))
5519

5620
return Result(preview=Preview(feedback="Valid Python syntax."))

0 commit comments

Comments
 (0)