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

@@ -11,12 +11,15 @@ use axum::{
routing::get,
Json, Router,
};
use picloud_shared::{ExecutionLog, Script, ScriptId, ScriptValidator, ValidationError};
use picloud_shared::{
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
};
use serde::Deserialize;
use crate::repo::{
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
};
use crate::sandbox::{CeilingError, SandboxCeiling};
/// State shared by control-plane handlers. Separates concerns so the
/// manager can validate at upload time without depending on the
@@ -25,6 +28,7 @@ pub struct AdminState<R, L> {
pub repo: Arc<R>,
pub logs: Arc<L>,
pub validator: Arc<dyn ScriptValidator>,
pub sandbox_ceiling: SandboxCeiling,
}
impl<R, L> Clone for AdminState<R, L> {
@@ -33,6 +37,7 @@ impl<R, L> Clone for AdminState<R, L> {
repo: self.repo.clone(),
logs: self.logs.clone(),
validator: self.validator.clone(),
sandbox_ceiling: self.sandbox_ceiling,
}
}
}
@@ -70,6 +75,11 @@ pub struct CreateScriptRequest {
pub source: String,
pub timeout_seconds: Option<i32>,
pub memory_limit_mb: Option<i32>,
/// Sandbox overrides; absent or empty `{}` means "use platform
/// defaults". Each non-null field is checked against the admin
/// ceiling at write time.
#[serde(default)]
pub sandbox: ScriptSandbox,
}
#[derive(Debug, Deserialize)]
@@ -83,6 +93,10 @@ pub struct UpdateScriptRequest {
pub source: Option<String>,
pub timeout_seconds: Option<i32>,
pub memory_limit_mb: Option<i32>,
/// `Some(sandbox)` replaces the stored overrides wholesale (use
/// `Some(ScriptSandbox::empty())` to clear them). Absent leaves
/// the stored value unchanged.
pub sandbox: Option<ScriptSandbox>,
}
#[allow(clippy::option_option)]
@@ -120,6 +134,7 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
Json(input): Json<CreateScriptRequest>,
) -> Result<(StatusCode, Json<Script>), ApiError> {
state.validator.validate(&input.source)?;
state.sandbox_ceiling.check(&input.sandbox)?;
let created = state
.repo
.create(NewScript {
@@ -128,6 +143,11 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
source: input.source,
timeout_seconds: input.timeout_seconds,
memory_limit_mb: input.memory_limit_mb,
sandbox: if input.sandbox.is_empty() {
None
} else {
Some(input.sandbox)
},
})
.await?;
Ok((StatusCode::CREATED, Json(created)))
@@ -141,6 +161,9 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
if let Some(src) = input.source.as_deref() {
state.validator.validate(src)?;
}
if let Some(sb) = input.sandbox.as_ref() {
state.sandbox_ceiling.check(sb)?;
}
let updated = state
.repo
.update(
@@ -151,6 +174,7 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
source: input.source,
timeout_seconds: input.timeout_seconds,
memory_limit_mb: input.memory_limit_mb,
sandbox: input.sandbox,
},
)
.await?;
@@ -205,6 +229,9 @@ pub enum ApiError {
#[error("invalid script: {0}")]
Invalid(#[from] ValidationError),
#[error("{0}")]
Ceiling(#[from] CeilingError),
#[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError),
}
@@ -214,7 +241,9 @@ impl IntoResponse for ApiError {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Invalid(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Invalid(_) | Self::Ceiling(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
}
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, self.to_string())
}