feat(interceptors): ordered before-chains + cycle guard + ctx plumbing (§9.4 M1+M2)

M1: introduces a clone-cheap InterceptorCtx { interceptors, self_engine,
limits } threaded into every SDK register fn (kv/docs/files/queue/pubsub/http)
so the non-KV services carry the hook seam (unused until M7-M11); KvHandle
collapses its three fields into one ictx.

M2: replaces the nearest-only resolver with ordered before/after chains. The
trait becomes resolve(cx, service, op) -> InterceptorChain { before, after }
(migration 0074 adds a phase column + phase-aware unique indexes). The before
-chain runs ancestor->app (depth DESC) so a group compliance guard can't be
bypassed by a descendant; single-marker behavior is byte-identical to before.
An identity cycle guard (thread-local visited-set keyed by script_id) denies a
detected cycle, alongside the existing binary re-entrancy break. Fail-closed
verdict preserved (allow only on #{ allowed: true }; a Dangling entry or a
missing engine back-ref denies); app_id still derives from cx.app_id only.

after-chains resolve but stay unused until M3. Pinned by two new interceptor
journeys (ancestor->app chain ordering; self-referential no-recurse).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-15 22:54:51 +02:00
parent eae2ee08f1
commit be0c618672
15 changed files with 623 additions and 200 deletions

View File

@@ -30,26 +30,23 @@
use std::sync::Arc;
use picloud_shared::{GroupKvService, InterceptorService, KvService, SdkCallCx, Services};
use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services};
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use super::bridge::{
block_on, dynamic_to_json_capped, json_to_dynamic, JsonSizeError, MAX_JSON_MATERIALIZE_BYTES,
};
use crate::engine::Engine;
use crate::sandbox::Limits;
use super::interceptor::InterceptorCtx;
/// Per-call handle captured by the Rhai SDK. Cheap to clone (a few Arcs plus an
/// owned string). Carries the §9.4 interceptor deps so `set`/`delete` can run a
/// owned string). Carries the §9.4 interceptor ctx so `set`/`delete` can run a
/// before-op allow/deny hook (the resolver + the `invoke()` re-entry engine).
#[derive(Clone)]
pub struct KvHandle {
collection: String,
service: Arc<dyn KvService>,
cx: Arc<SdkCallCx>,
interceptors: Arc<dyn InterceptorService>,
self_engine: Option<Arc<Engine>>,
limits: Limits,
ictx: InterceptorCtx,
}
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
@@ -57,28 +54,24 @@ pub struct KvHandle {
/// collections never grow triggers) and a script's choice of private-vs-shared
/// scope is explicit. Routes through the `GroupKvService`, which resolves the
/// owning group from `cx.app_id` (the script never names a group). Carries the
/// §9.4 interceptor deps too so a `(kv, set/delete)` guard covers shared-
/// §9.4 interceptor ctx too so a `(kv, set/delete)` guard covers shared-
/// collection writes — otherwise the shared handle would be a silent bypass.
#[derive(Clone)]
pub struct GroupKvHandle {
collection: String,
service: Arc<dyn GroupKvService>,
cx: Arc<SdkCallCx>,
interceptors: Arc<dyn InterceptorService>,
self_engine: Option<Arc<Engine>>,
limits: Limits,
ictx: InterceptorCtx,
}
pub(super) fn register(
engine: &mut RhaiEngine,
services: &Services,
cx: Arc<SdkCallCx>,
limits: Limits,
self_engine: Option<Arc<Engine>>,
ictx: InterceptorCtx,
) {
let kv_service = services.kv.clone();
let group_kv_service = services.group_kv.clone();
let interceptors = services.interceptors.clone();
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
// the `kv` static module so the script-visible calls are `kv::collection`
@@ -87,8 +80,7 @@ pub(super) fn register(
{
let kv_service = kv_service.clone();
let cx = cx.clone();
let interceptors = interceptors.clone();
let self_engine = self_engine.clone();
let ictx = ictx.clone();
module.set_native_fn(
"collection",
move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> {
@@ -99,9 +91,7 @@ pub(super) fn register(
collection: name.to_string(),
service: kv_service.clone(),
cx: cx.clone(),
interceptors: interceptors.clone(),
self_engine: self_engine.clone(),
limits,
ictx: ictx.clone(),
})
},
);
@@ -109,8 +99,7 @@ pub(super) fn register(
{
let group_kv_service = group_kv_service.clone();
let cx = cx.clone();
let interceptors = interceptors.clone();
let self_engine = self_engine.clone();
let ictx = ictx.clone();
module.set_native_fn(
"shared_collection",
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
@@ -121,9 +110,7 @@ pub(super) fn register(
collection: name.to_string(),
service: group_kv_service.clone(),
cx: cx.clone(),
interceptors: interceptors.clone(),
self_engine: self_engine.clone(),
limits,
ictx: ictx.clone(),
})
},
);
@@ -188,10 +175,8 @@ fn register_set(engine: &mut RhaiEngine) {
// §9.4 before-op interceptor (allow/deny). A denial errors here and
// the write below never runs.
super::interceptor::run_before(
&handle.interceptors,
handle.self_engine.as_ref(),
&handle.ictx,
&handle.cx,
handle.limits,
"kv",
"set",
&handle.collection,
@@ -244,10 +229,8 @@ fn register_delete(engine: &mut RhaiEngine) {
"delete",
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
super::interceptor::run_before(
&handle.interceptors,
handle.self_engine.as_ref(),
&handle.ictx,
&handle.cx,
handle.limits,
"kv",
"delete",
&handle.collection,
@@ -271,10 +254,8 @@ fn group_run_before(
value: Option<&serde_json::Value>,
) -> Result<(), Box<EvalAltResult>> {
super::interceptor::run_before(
&handle.interceptors,
handle.self_engine.as_ref(),
&handle.ictx,
&handle.cx,
handle.limits,
"kv",
op,
&handle.collection,