Skip to content

FEAT: program execution scorer (scorer phase 10b) - #3218

Open
WatchTree-19 wants to merge 6 commits into
microsoft:mainfrom
WatchTree-19:feat/program-execution-scorer
Open

WatchTree-19 wants to merge 6 commits into
microsoft:mainfrom
WatchTree-19:feat/program-execution-scorer

Conversation

@WatchTree-19

@WatchTree-19 WatchTree-19 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Description

Adds experimental program execution and behavior scoring, implementing phase 10b of the scorer design and roadmap. It checks what submitted code actually does for specified inputs, rather than judging a description of its behavior.

Reusable execution, separate from scoring

  • pyrit._code_execution provides a small client contract for caller-configured execution backends. Targets, converters, dataset generators, and scorers can use it without importing scoring or memory. Its shared input and result types are private and experimental.
  • The client accepts verbatim code, a trusted runtime profile name, arguments, and stdin. It returns bounded output, exit information, timeout/resource-limit outcomes, and available runtime provenance. It does not interpret results or receive expected answers.
  • The caller supplies the backend and trusted runtimes. PyRIT does not start the daemon, build or pull images, or maintain reusable environments.

Optional Docker adapter

Each invocation runs in a fresh container using a pre-pulled, digest-pinned image. The adapter disables networking and daemon logging, runs as a non-root user with capabilities dropped and privilege escalation disabled, mounts the program read-only, and applies memory, CPU, process, time, and output limits. It rejects backends that do not report the required resource support and images that declare volumes. Submitted source stays inside a private host workspace; cleanup handles cancellation and removes owned containers and anonymous volumes.

Stronger isolation can use the same client contract through a different caller-provided backend.

Scoring and replay

ProgramExecutionSource resolves program references and captures evidence. ProgramExecutionScorer keeps the expected results from ProgramBehaves separate and compares them with that evidence. Every scoring call executes afresh; the saved observation identifies the run. Explicit replay evaluates saved output without another execution.

All cases passing yields true; any proven case failure yields false. Infrastructure failures and insufficient evidence leave the verdict undetermined. Truncated output retains its uncertainty. Code is not extracted from surrounding prose, and existing scorer behavior is unchanged.

Adds ProgramBehaves, ProgramExecutionScorable, ProgramExecutionSource,
the SandboxClient protocol with a DockerSandboxClient, and
ProgramExecutionScorer. The scorer sends the source only the runtime
profile and invocations; expected results stay with the scorer. Program
failure is observed evidence, a sandbox failure is undetermined, and a
stored execution can be judged again without running the program.
Comment thread pyrit/score/observation/program_execution_source.py Outdated
Comment thread pyrit/score/observation/docker_sandbox_client.py Outdated
Comment thread pyrit/score/observation/docker_sandbox_client.py Outdated
Comment thread pyrit/score/observation/docker_sandbox_client.py Outdated
Comment thread pyrit/score/observation/docker_sandbox_client.py Outdated
Comment thread pyrit/score/true_false/program_execution_scorer.py Outdated
- ProgramExecutionSource shares one in-flight run between concurrent callers
  of the same attempt and cancels it only when every caller has gone.
- DockerSandboxClient reports an exit code only for an exited container; a
  client that exits while the container is still running is a sandbox error.
- Docker management commands are killed and reaped on cancellation as well
  as on timeout.
- A failed container removal is reported as a warning with the container
  name, kept in the observation metadata, and stops further cases.
- The read-only program file is made writable before its directory is
  removed, so cleanup also works on Windows.
- ContentEntryScorable is accepted as a program reference, so a saved
  score anchor can be scored again.
@WatchTree-19

Copy link
Copy Markdown
Contributor Author

Thanks Roman, all six addressed in the latest commit, each with a regression test:

  • Concurrent callers for the same attempt now share one in-flight run, cancelled only when every caller has gone.
  • The client inspects the container status and only reports an exit code for an exited container; an attach failure while it is still running is a SandboxExecutionError.
  • Management commands are killed and reaped in a finally, so cancellation no longer leaves one running.
  • A failed rm is reported with the container name, kept in the observation's sandbox_warnings metadata, and stops further cases.
  • The program file is made writable before the directory is removed, covered by a mocked-invocation test that runs on every platform.
  • ContentEntryScorable is accepted as a program reference, with a round-trip test that scores a saved score's anchor.

Comment thread pyrit/score/observation/program_execution_source.py Outdated
Comment thread pyrit/score/observation/docker_sandbox_client.py Outdated
Comment thread pyrit/score/true_false/program_execution_scorer.py Outdated
…ations

- DockerSandboxClient uses one deadline for the whole invocation, including the
  attached client's exit. A client still running after its streams close is ended
  at the deadline, and the container's own state then decides the result, so a
  stalled client is neither unbounded nor mistaken for a program timeout.
- ProgramExecutionSource waits for the sandbox's cleanup when the last caller of an
  attempt is cancelled, so cancellation still means the environment is gone.
- Memory links a new observation whose ID is already stored when the stored row is
  identical, checked under the observation row locks; different evidence under the
  same ID is still rejected. ProgramExecutionScorer drops its separate pre-check, so
  concurrent scoring calls that share one acquisition both persist their scores.
@WatchTree-19

Copy link
Copy Markdown
Contributor Author

Thanks Roman, all three are addressed in the latest commit.

  1. The timeout now covers the whole invocation, including the client exiting after its streams close. If the client stalls, it gets killed, and the container's own state decides the result, so a container that is still running comes back as a sandbox error rather than a pass. There are two new tests where a fake client closes stdout and stderr and then hangs, one over a finished container and one over a running one.
  2. When the last caller is cancelled, it now waits for the sandbox cleanup to finish before its own cancellation completes. The test holds cleanup open and checks that cancelling waits for it.
  3. I took out the scorer's pre-check. Memory now links an identical stored observation inside the same transaction, under its row lock, and still rejects a different observation that reuses a stored ID. I added a test with two concurrent score_async calls on real SQLite, held together at the write. It reproduces your error on the previous code and now stores one observation that both scores link to.

Comment thread pyrit/score/observation/docker_sandbox_client.py Outdated
Comment thread pyrit/score/observation/program_execution_source.py Outdated
Comment thread pyrit/score/observation/docker_sandbox_client.py Outdated
Comment thread pyrit/score/observation/docker_sandbox_client.py Outdated
Comment thread pyrit/score/observation/docker_sandbox_client.py Outdated
…ork off the loop

- DockerSandboxClient runs containers with --log-driver none, so the daemon keeps
  no copy of the output, and sets --entrypoint from the profile, so an image's own
  entrypoint never changes the command that runs. Building the run arguments and
  waiting on the attached client are now separate helpers.
- Writing and removing the program directory runs in a worker thread; a cancelled
  caller waits for that work before its cancellation completes.
- ProgramExecutionSource gives an attempt's reusable evidence an ID derived from the
  source and the attempt, and loads it from memory once the cache has let it go,
  so a stored attempt is never run again. Errors keep fresh IDs and are retried.
@WatchTree-19

Copy link
Copy Markdown
Contributor Author

Thanks Rich, all five are in the latest commit.

  1. Containers now run with --log-driver none, so the daemon keeps nothing beyond what the client retains. The test asserts the flag on the generated arguments, and on a real daemon attached output still arrives and is capped (a 1 MB write kept 128 bytes).
  2. An attempt's reusable evidence now has an ID derived from the source and the attempt, and on a cache miss the source loads that observation from memory before running anything. Your sequence (score, score, 256 other attempts, score again) now runs the program once and both scores link to one observation. Errors keep fresh IDs and are retried. Evidence that was never stored can still run again after eviction, but memory refuses a different run of an attempt that already has stored evidence, so contradicting results are never stored. There is a test with two sources racing one attempt.
  3. --entrypoint now comes from the profile, so the recorded command is the one that runs. The fake Docker client applies an image entrypoint the way Docker does, and on a real daemon an image with ENTRYPOINT ["python3"] exited 2 before this change and now passes.
  4. Creating and removing the program directory runs in a worker thread, and a cancelled call waits for that work before its cancellation completes, so cleanup order is kept. The tests block the file work until the event loop releases it, and check that cancelling during directory creation still leaves nothing behind.
  5. _run_arguments builds the command line and _collect_output_async handles the streams against the one deadline. _run_invocation_async keeps the process and container cleanup.

Extract reusable execution clients from scoring and make shared execution data types private. Remove implicit execution reuse, obsolete helpers, and duplicate test scaffolding. Preserve exact source bytes, bounded output, and cancellation-safe cleanup.

Validate Docker resource support, reject image-declared volumes, remove owned anonymous volumes, and protect submitted source with a private host workspace. Update scoring replay, scoped regressions, real Docker integration, and paired documentation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0bdf701a-5fa6-44c0-ba7e-82a5beb6722d
@richlundeen Richard Lundeen (richlundeen) changed the title FEAT: program execution scorer in an isolated sandbox (scorer phase 10b) FEAT: program execution scorer (scorer phase 10b) Oct 23, 2026
Keep the single-image Docker example as the only exception to caller-owned execution environments without expanding the framework section.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0bdf701a-5fa6-44c0-ba7e-82a5beb6722d

This branch has not been deployed

No deployments
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.

3 participants