Skip to content

Named lab actions, and pairing scopes so a lab page cannot run arbitrary code - #2

Merged
thinkinginmath merged 1 commit into
mainfrom
feat/lab-actions
Sep 19, 2026
Merged

thinkinginmath merged 1 commit into
mainfrom
feat/lab-actions

Conversation

@thinkinginmath

Copy link
Copy Markdown
Collaborator

Lets a course page run a lab's declared commands, and nothing else, on the learner's machine.

Why

Today a paired site can run any Python through /cell/run. A lab page only needs to start a few prepared commands, such as docker compose up and python demo.py …. With this PR, a site paired with the new actions scope can only do that. If the page were compromised, its token could run only commands the learner already approved.

Pairing scopes

  • POST /pairing/request takes {"scope": "code" | "actions"}. The default is code, which is today's behaviour, so existing clients are unaffected.
  • The terminal prompt now shows the access being granted, e.g. Access: actions - this site could run only the named actions of labs you approve one by one.
  • actions tokens get 403 from /kernel/* and /cell/*.
  • Pairings saved before this change, loopback origins and --no-pairing all count as code.

Named lab actions

  • lab.toml in the lab repo (schema 1) declares [actions.<name>] entries. Each has a label, a description, a timeout, and steps given as argv lists, run without a shell. Unknown keys are errors.
  • POST /labs/prepare {repo, commit}:
    • accepts https URLs only (GIT_ALLOW_PROTOCOL, so no ssh, file or ext::), without credentials, and a full 40-character SHA;
    • fetches exactly that commit (--depth 1, no submodules, never prompts for credentials) and reads lab.toml;
    • once per site, repo, commit and manifest, prompts in the terminal, listing every command;
    • creates the lab's venv, which comes first on PATH for its actions.
  • Runs:
    • POST /labs/runs starts one, one at a time per lab.
    • GET /labs/runs/{id}?offset= returns status plus new output.
    • POST /labs/runs/{id}/stop sends SIGINT to the process group, then SIGKILL.
    • GET /labs/runs lists runs, so a reloaded page can find one still going.
    • A run is visible only to the site that started it. Timeouts are enforced, and shutdown stops and waits for running actions.

The docs cover all of this (protocol.md, integration.md, security.md, README). The security doc states the limit plainly: approving a lab pins which commands and which code run, down to the commit; it does not sandbox them.

Verified end to end (Linux)

This branch's runtime ran with pairing required, against SciMigo/restate-durable-agent-demo @ e7bdf44 (its lab-toml branch):

  • paired as https://scimigo.com with scope actions; /cell/run returned 403;
  • /labs/prepare showed the approval prompt with all nine actions' commands, and was approved once, in 14.5 s; the second prepare didn't prompt;
  • setup ran docker compose up and pip install; 1.1 → model calls: 3 weather calls: 1, the same as the recorded run; teardown removed the container.

Tests

tests/test_labs.py has 35 tests:

  • manifest rules; source rules; fetching a commit from a file:// repo (a protocol allowed only in tests) and reusing it;
  • approval once, and a declined lab being unable to run;
  • runs: success, a failing step ending the action, Stop, one at a time, timeout, and other sites not seeing a run;
  • scope enforcement, and the scope carried through the pairing request.

167 passed; ruff, format and strict mypy are clean.

After this merges, the demo repo's lab-toml branch merges too, and then scimigo.com's Restate lab page gets its run panel.

🤖 Generated with Claude Code

…ary code

A course page that only runs a lab's prepared commands no longer needs a token that can run any
Python on the learner's machine.

Pairing scopes
- POST /pairing/request takes {"scope": "code" | "actions"}. The default stays "code", so
  existing clients keep working. The terminal prompt says what the scope allows.
- "actions" tokens get 403 from /kernel and /cell; require_auth now means code scope, and
  require_lab_auth accepts both. Pairings saved before scopes existed, loopback origins and
  --no-pairing count as "code".

Named lab actions (labs.py, api/labs.py)
- A lab repository declares actions in lab.toml (schema 1): argv steps run without a shell,
  with a label, description and timeout. Unknown keys and anything out of range are errors.
- POST /labs/prepare {repo, commit}:
  - accepts https URLs only (GIT_ALLOW_PROTOCOL), no credentials, full SHA;
  - fetches exactly that commit (--depth 1, no submodules, no credential prompt) into
    <runtime dir>/labs/<repo>/<sha[:12]>;
  - reads lab.toml;
  - once per origin + repo + commit + manifest hash, prompts in the terminal with every
    command, then creates the lab's venv.
- POST /labs/runs starts an action, one run at a time per lab. Poll GET /labs/runs/{id}?offset
  for status and new output; POST .../stop sends SIGINT to the process group, then SIGKILL.
  GET /labs/runs lists this origin's runs; a run is visible only to the origin that started it.
  Output is kept to the last 1M characters, and shutdown stops and awaits running actions.
- runtime/info advertises lab_actions and pairing_scopes.

Verified end to end on Linux:
- The branch runtime ran with pairing required, against SciMigo/restate-durable-agent-demo
  @ e7bdf44 (branch lab-toml, which adds its lab.toml).
- Pairing as https://scimigo.com with scope "actions" worked, and /cell/run returned 403.
- Prepare asked for approval once, listing all nine actions' commands, in 14.5 s; the second
  prepare did not ask.
- setup ran docker compose up and pip install. 1.1 gave "model calls: 3  weather calls: 1",
  the same as the recorded run, and teardown removed the container.

Tests: tests/test_labs.py covers the manifest rules, source rules, fetching a commit from a
file:// repo (test-only protocol) and reusing it, approval once and a declined approval, and
runs: success, a failing step ending the action, stop, one at a time, timeout, and other
origins not seeing a run. It also covers scope enforcement and the scope in pairing requests.
167 passed; ruff, format and mypy clean.

Docs: protocol.md (scopes, lab actions, lab.toml), integration.md (the flow for a lab page),
security.md (what approving a lab does and does not mean: it pins which commands and which
code run, it does not sandbox them), README, CLAUDE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thinkinginmath
thinkinginmath merged commit 7c8c260 into main Sep 19, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant