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:
@@ -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};
|
||||
|
||||
58
crates/shared/src/sandbox.rs
Normal file
58
crates/shared/src/sandbox.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user