feat: per-script Rhai sandbox overrides with admin ceiling

Adds optional per-script overrides for the six Rhai sandbox knobs
(max_operations, max_string_size, max_array_size, max_map_size,
max_call_levels, max_expr_depth). The executor merges its defaults
with each script's overrides on every call; the manager validates
overrides against an admin-set ceiling at write time, so the
executor trusts whatever is stored.

Storage chose JSONB on the existing scripts table over six new
columns: lets future knobs land as code-only changes, keeps the
sparse common case (most scripts override nothing) cheap to store
and serialize, and matches how the manager + executor pass the
config across the wire.

  * 0002_sandbox.sql — ALTER TABLE scripts ADD COLUMN sandbox
    JSONB NOT NULL DEFAULT '{}'
  * shared::ScriptSandbox — six Option<u64> fields with
    deny_unknown_fields so typos surface as 422
  * Script.sandbox + ExecRequest.sandbox_overrides — typed end
    to end; cluster mode just serializes the same struct
  * executor-core::Limits::with_overrides — field-by-field
    replacement; tests cover the override actually tightening
    the live engine
  * manager-core::SandboxCeiling — built-in conservative
    defaults (10M ops, 1 MiB strings, 100k array/map, 128
    call/expr depth); env vars override per knob, invalid
    values warn-and-skip rather than blocking boot
  * manager-core admin API — POST/PUT accept `sandbox`; values
    above the ceiling return 422 with the specific field +
    requested + ceiling; absent or `{}` keeps platform defaults
  * picloud all-in-one — wires SandboxCeiling::from_env() into
    AdminState
  * memory_limit_mb stays in the schema, marked v1.3+ advisory
    (no enforcement until OS-level isolation lands with
    cluster-mode executors)

Verified live through Caddy:
  * /version reports schema 2, product 0.3.0
  * Script with max_operations: 500 → 507 on a 10k-iteration loop
  * Same script after PUT raising to 1M → succeeds, returns 10000
  * POST with max_operations: 1_000_000_000 → 422 (exceeds ceiling)

Tests:
  * 13 executor-core unit tests (added 2 for override semantics)
  * 20 integration tests (added 6 for sandbox CRUD + ceiling +
    unknown-field rejection + executor honoring overrides)
  * default cargo test --workspace stays green (integration tests
    remain #[ignore]'d until DATABASE_URL is set)

Bumps:
  * schema 1 → 2
  * product 0.2.0 → 0.3.0
  * SDK unchanged (scripts see nothing new)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-23 16:26:12 +02:00
parent 4baaead642
commit f51924fdbc
18 changed files with 491 additions and 22 deletions

View File

@@ -8,6 +8,7 @@ pub mod error;
pub mod execution_log;
pub mod ids;
pub mod log_sink;
pub mod sandbox;
pub mod script;
pub mod validator;
pub mod version;
@@ -16,6 +17,7 @@ pub use error::Error;
pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use ids::{ExecutionId, RequestId, ScriptId};
pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use sandbox::ScriptSandbox;
pub use script::Script;
pub use validator::{ScriptValidator, ValidationError};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};

View File

@@ -0,0 +1,58 @@
//! Per-script overrides for the Rhai sandbox limits.
//!
//! All fields are optional: `None` means "use the executor's platform
//! default for this knob". Stored as JSONB in the `scripts.sandbox`
//! column so adding new knobs in the future is a code-only change.
//!
//! Values are clamped against the admin-set ceiling at write time
//! (in `manager-core`). The executor trusts what's stored: it merges
//! defaults + overrides per call and applies the result.
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ScriptSandbox {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_operations: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_string_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_array_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_map_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_call_levels: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_expr_depth: Option<u64>,
}
impl ScriptSandbox {
#[must_use]
pub const fn empty() -> Self {
Self {
max_operations: None,
max_string_size: None,
max_array_size: None,
max_map_size: None,
max_call_levels: None,
max_expr_depth: None,
}
}
/// Returns true if every field is `None` (no overrides).
#[must_use]
pub const fn is_empty(&self) -> bool {
self.max_operations.is_none()
&& self.max_string_size.is_none()
&& self.max_array_size.is_none()
&& self.max_map_size.is_none()
&& self.max_call_levels.is_none()
&& self.max_expr_depth.is_none()
}
}

View File

@@ -1,7 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::ScriptId;
use crate::{ScriptId, ScriptSandbox};
/// A user-uploaded Rhai script and its execution configuration.
///
@@ -17,6 +17,16 @@ pub struct Script {
pub source: String,
pub timeout_seconds: u32,
/// Per-script overrides for Rhai sandbox limits. Empty = platform
/// defaults. Values are admin-ceiling-clamped at write time.
#[serde(default)]
pub sandbox: ScriptSandbox,
/// **v1.3+ advisory only.** Real heap limits require OS-level
/// isolation (cgroups, process limits) which lands with cluster-mode
/// executor sandboxing. The field stays in the schema so we don't
/// have to add it back when that's built.
pub memory_limit_mb: u32,
pub created_at: DateTime<Utc>,