fix(audit-2026-06-11/H-C1): engine.on_progress deadline check terminates runaway scripts

tokio::time::timeout over JoinHandle only drops the future — the
spawn_blocking OS thread keeps running until the Rhai script self-
completes or hits the per-op budget. A `loop {}` body with a generous
max_operations could pin a blocking worker for tens of seconds; on Pi-
class hardware one anonymous-callable script call could permanently
subtract a worker from the pool.

Installs an `engine.on_progress` hook that consults a thread-local
deadline; when `Instant::now() >= deadline` the hook returns `Some(_)`,
triggering `ErrorTerminated` inside the Rhai loop. The deadline is set
by `Engine::execute_with_deadline` / `Engine::execute_ast_with_deadline`
via an RAII `DeadlineGuard`, called from the orchestrator client; the
old `execute` / `execute_ast` paths are unchanged so tests, validation,
and the parse-only path keep working.

Invoke re-entry (`sdk/invoke.rs`) inherits the parent's deadline
transparently — the thread-local is set for the entire spawn_blocking
lifetime, and Rhai is single-threaded so the same thread services the
parent + every sub-script.

New tests:
* `deadline_terminates_a_runaway_loop` — `loop {}` body with
  `max_operations = u64::MAX` and a 100 ms deadline aborts within 2 s
  (typically <200 ms). Maps to `ExecError::Runtime`.
* `no_deadline_set_does_not_abort` — `None` deadline keeps the pre-
  audit behavior; the op-budget still bites.

Audit ref: security_audit/05_sandbox_exec.md#f-se-h-01.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-11 20:33:01 +02:00
parent 9067225945
commit 99eb0025fa
3 changed files with 153 additions and 13 deletions

View File

@@ -1,6 +1,6 @@
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
@@ -183,13 +183,12 @@ impl ExecutorClient for LocalExecutorClient {
) -> Result<ExecResponse, ExecError> {
// Acquire before spending any wall-clock budget. The permit is
// held by this future; on `tokio::time::timeout` firing, the
// future drops and the permit returns to the pool — but the
// detached `spawn_blocking` thread keeps running until the
// Rhai script finishes (or panics). So in-use blocking threads
// can briefly exceed the gate's permit count after a timeout.
// That is intentional: a new admission can be served while the
// already-doomed script winds down, which is preferable to
// wedging the slot for the worst-case timeout duration.
// future drops and the permit returns to the pool. Audit
// 2026-06-11 H-C1: the detached `spawn_blocking` thread is now
// also bounded by the Rhai `on_progress` deadline hook (installed
// via `execute_with_deadline`), so a runaway script terminates
// soon after the deadline instead of holding its OS thread until
// the per-op budget self-exhausts.
let _permit =
self.gate
.try_acquire()
@@ -201,10 +200,13 @@ impl ExecutorClient for LocalExecutorClient {
let timeout = timeout.min(HARD_TIMEOUT_CAP);
let timeout_secs = u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX);
let deadline = Instant::now() + timeout;
let engine = self.engine.clone();
let source = source.to_string();
let join = tokio::task::spawn_blocking(move || engine.execute(&source, req));
let join = tokio::task::spawn_blocking(move || {
engine.execute_with_deadline(&source, req, Some(deadline))
});
match tokio::time::timeout(timeout, join).await {
Err(_) => Err(ExecError::Timeout(timeout_secs)),
@@ -235,9 +237,12 @@ impl ExecutorClient for LocalExecutorClient {
let timeout = timeout.min(HARD_TIMEOUT_CAP);
let timeout_secs = u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX);
let deadline = Instant::now() + timeout;
let engine = self.engine.clone();
let join = tokio::task::spawn_blocking(move || engine.execute_ast(&ast, req));
let join = tokio::task::spawn_blocking(move || {
engine.execute_ast_with_deadline(&ast, req, Some(deadline))
});
match tokio::time::timeout(timeout, join).await {
Err(_) => Err(ExecError::Timeout(timeout_secs)),