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

@@ -0,0 +1,103 @@
//! Admin-set ceiling for per-script sandbox overrides.
//!
//! The orchestrator-core's default `Limits` is what scripts get when they
//! don't override. The ceiling here is the per-field maximum that a
//! script's override is allowed to request. Validation runs at write
//! time so the executor can trust whatever's stored.
use std::env;
use picloud_shared::ScriptSandbox;
use thiserror::Error;
/// Maximum allowed value per sandbox knob. Loaded from env vars at
/// startup (with conservative built-in defaults). A `None` field means
/// "unbounded" — only useful if the operator explicitly clears the
/// ceiling for a given knob (it must still fit `u64`).
#[derive(Debug, Clone, Copy)]
pub struct SandboxCeiling {
pub max_operations: u64,
pub max_string_size: u64,
pub max_array_size: u64,
pub max_map_size: u64,
pub max_call_levels: u64,
pub max_expr_depth: u64,
}
impl SandboxCeiling {
/// Conservative built-in ceiling. Matches the executor's defaults —
/// scripts can request anything between zero and this, but no
/// higher. Operators can widen via env vars (see `from_env`).
#[must_use]
pub const fn conservative() -> Self {
Self {
max_operations: 10_000_000,
max_string_size: 1024 * 1024, // 1 MiB
max_array_size: 100_000,
max_map_size: 100_000,
max_call_levels: 128,
max_expr_depth: 128,
}
}
/// Read overrides from env vars, falling back to `conservative()`
/// for any unset knob. Invalid values are ignored with a warning
/// (we'd rather start with the conservative default than refuse to
/// boot on a typo).
#[must_use]
pub fn from_env() -> Self {
let mut c = Self::conservative();
macro_rules! load {
($field:ident, $key:expr) => {
if let Ok(v) = env::var($key) {
match v.parse::<u64>() {
Ok(n) => c.$field = n,
Err(e) => tracing::warn!(env = $key, error = %e, "ignoring invalid sandbox ceiling value"),
}
}
};
}
load!(max_operations, "PICLOUD_SANDBOX_MAX_OPERATIONS");
load!(max_string_size, "PICLOUD_SANDBOX_MAX_STRING_SIZE");
load!(max_array_size, "PICLOUD_SANDBOX_MAX_ARRAY_SIZE");
load!(max_map_size, "PICLOUD_SANDBOX_MAX_MAP_SIZE");
load!(max_call_levels, "PICLOUD_SANDBOX_MAX_CALL_LEVELS");
load!(max_expr_depth, "PICLOUD_SANDBOX_MAX_EXPR_DEPTH");
c
}
/// Returns `Err` if any override exceeds the ceiling on the same
/// field. Empty overrides (`ScriptSandbox::empty()`) always pass.
pub fn check(&self, s: &ScriptSandbox) -> Result<(), CeilingError> {
check_field("max_operations", s.max_operations, self.max_operations)?;
check_field("max_string_size", s.max_string_size, self.max_string_size)?;
check_field("max_array_size", s.max_array_size, self.max_array_size)?;
check_field("max_map_size", s.max_map_size, self.max_map_size)?;
check_field("max_call_levels", s.max_call_levels, self.max_call_levels)?;
check_field("max_expr_depth", s.max_expr_depth, self.max_expr_depth)?;
Ok(())
}
}
fn check_field(name: &'static str, value: Option<u64>, ceiling: u64) -> Result<(), CeilingError> {
if let Some(v) = value {
if v > ceiling {
return Err(CeilingError::Exceeded {
field: name,
requested: v,
ceiling,
});
}
}
Ok(())
}
#[derive(Debug, Error, Clone)]
pub enum CeilingError {
#[error("sandbox override `{field}` = {requested} exceeds admin ceiling of {ceiling}")]
Exceeded {
field: &'static str,
requested: u64,
ceiling: u64,
},
}