feat(interceptors): KV after-hooks + before-hook data transform (§9.4 M3+M4)

M4: a before-interceptor returning #{ allowed: true, data: ... } rewrites the
value actually written (threaded through the chain; each hook sees the prior
transform), size-capped at MAX_JSON_MATERIALIZE_BYTES. Delete never transforms.

M3: an 'after' phase interceptor runs once the write has committed, with the
write's result in its payload. After-hooks observe/deny but CANNOT roll back —
a deny surfaces as an operation error while the write persists (documented at
the call site).

Adds phase authoring end to end: a [[interceptors]] entry gains phase =
before|after (default before), threaded through the plan wire, BundleInterceptor,
validate (phase in {before,after}), the reconcile diff/insert/prune key
(service/op/phase), and the repo (insert/delete/list_for_owner/list_on_app_chain,
+ the marker's phase). run_before now returns the transformed value; run_after is
new; both share one fail-closed per-entry runner. Pinned by two journeys: a
before-hook transforms the stored value, and an after-delete hook sees
result==true, denies, yet the key stays deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-16 19:55:44 +02:00
parent be0c618672
commit 5592f1c97e
7 changed files with 496 additions and 162 deletions

View File

@@ -25,7 +25,7 @@
use std::cell::{Cell, RefCell};
use std::sync::Arc;
use picloud_shared::{InterceptorEntry, InterceptorService, ScriptId, SdkCallCx};
use picloud_shared::{InterceptorChain, InterceptorEntry, InterceptorService, ScriptId, SdkCallCx};
use rhai::EvalAltResult;
use serde_json::{json, Value as Json};
use tokio::runtime::Handle as TokioHandle;
@@ -102,12 +102,20 @@ impl Drop for CycleGuard {
}
}
/// The parsed outcome of ONE allowing hook: the operation passes, optionally
/// carrying a `data` transform (M4) the caller writes instead of the original.
struct HookAllow {
/// `Some` iff the hook returned a `data` key alongside `allowed: true`.
data: Option<Json>,
}
/// Run the before-op interceptor CHAIN for `(service, op)` if any are
/// registered. Returns `Ok(())` to allow the operation (un-hooked, or every
/// before-interceptor explicitly allowed it) or an `Err` runtime error to deny
/// it (the caller must NOT then perform the write). A single deny
/// short-circuits — the write is blocked. `value` is the payload being written
/// (`None` for delete).
/// registered. Returns `Ok(Some(value))` when a before-hook rewrote the payload
/// via a `data` transform (M4) — the caller writes THAT instead of the original
/// — or `Ok(None)` to write the original unchanged (un-hooked, or every hook
/// allowed without transforming). An `Err` denies the operation (the caller
/// must NOT perform the write); the FIRST deny short-circuits. `value` is the
/// original payload (`None` for delete, which never transforms).
#[allow(clippy::too_many_arguments)]
pub(super) fn run_before(
ictx: &InterceptorCtx,
@@ -117,33 +125,103 @@ pub(super) fn run_before(
collection: &str,
key: &str,
value: Option<&Json>,
) -> Result<(), Box<EvalAltResult>> {
) -> Result<Option<Json>, Box<EvalAltResult>> {
// Re-entrancy break: writes performed BY an interceptor (or anything it
// invokes) are not themselves intercepted — otherwise an "allow + write"
// hook would recurse to the depth cap and deny the original op.
if currently_in_interceptor() {
return Ok(());
return Ok(None);
}
let chain = resolve_chain(ictx, cx, service, op)?;
if chain.before.is_empty() {
return Ok(None);
}
// Thread the (possibly rewritten) value through the chain: each before-hook
// sees the prior hook's transform in `value`, and the write uses the final.
let mut current: Option<Json> = value.cloned();
let mut transformed = false;
for entry in &chain.before {
let payload = op_payload(service, op, collection, key, current.as_ref(), cx, None);
let outcome = run_one_hook(ictx, cx, entry, service, op, &payload)?;
// M4 data-transform: honored ONLY on an allowing hook (the verdict parse
// already enforced fail-closed). Delete has no value to rewrite.
if let Some(data) = outcome.data {
if value.is_some() {
enforce_transform_size(service, op, &data)?;
current = Some(data);
transformed = true;
}
}
}
Ok(transformed.then(|| current.unwrap_or(Json::Null)))
}
/// Run the after-op interceptor CHAIN (M3) once the write has committed.
/// `result` is the write's outcome (serialized), surfaced to the hook so it can
/// observe/audit. After-hooks CANNOT roll back — the write already happened; a
/// deny here surfaces as an operation error to the caller but the write
/// persists. Returns `Ok(())` when every after-hook allowed (or none exist), an
/// `Err` on the first deny.
#[allow(clippy::too_many_arguments)]
pub(super) fn run_after(
ictx: &InterceptorCtx,
cx: &Arc<SdkCallCx>,
service: &'static str,
op: &'static str,
collection: &str,
key: &str,
value: Option<&Json>,
result: Json,
) -> Result<(), Box<EvalAltResult>> {
if currently_in_interceptor() {
return Ok(());
}
let chain = resolve_chain(ictx, cx, service, op)?;
if chain.after.is_empty() {
return Ok(());
}
for entry in &chain.after {
let payload = op_payload(service, op, collection, key, value, cx, Some(&result));
// After-hooks observe/deny; a returned `data` is ignored (the write is
// already durable, so there is nothing to rewrite for the kv slice).
run_one_hook(ictx, cx, entry, service, op, &payload)?;
}
Ok(())
}
/// Resolve the before+after chain for `(service, op)` — every marker visible on
/// the calling app's chain, ordered per phase. An `Err` (no tokio runtime, or a
/// backend failure) makes the caller fail closed rather than silently allow.
fn resolve_chain(
ictx: &InterceptorCtx,
cx: &Arc<SdkCallCx>,
service: &'static str,
op: &'static str,
) -> Result<InterceptorChain, Box<EvalAltResult>> {
let handle = TokioHandle::try_current()
.map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?;
let interceptors = ictx.interceptors.clone();
let cx = cx.clone();
handle
.block_on(async move { interceptors.resolve(&cx, service, op).await })
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))
}
// (1) Resolve the whole before+after chain, each entry sealed to its
// declaring owner. `before` is ordered ancestor→app (an outer/group guard
// runs first). `after` is unused this milestone.
let chain = {
let interceptors = ictx.interceptors.clone();
let cx = cx.clone();
handle
.block_on(async move { interceptors.resolve(&cx, service, op).await })
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))?
};
// Un-hooked: the ONLY path that allows without running anything.
if chain.before.is_empty() {
return Ok(());
}
let payload = json!({
/// Build the operation-context payload handed to a hook. `result` is `Some` only
/// for an after-hook (the write's outcome).
fn op_payload(
service: &str,
op: &str,
collection: &str,
key: &str,
value: Option<&Json>,
cx: &SdkCallCx,
result: Option<&Json>,
) -> Json {
let mut m = json!({
"service": service,
"action": op,
"collection": collection,
@@ -152,88 +230,114 @@ pub(super) fn run_before(
"caller_script_id": cx.script_id.to_string(),
"caller_execution_id": cx.execution_id.to_string(),
});
if let (Json::Object(obj), Some(r)) = (&mut m, result) {
obj.insert("result".to_string(), r.clone());
}
m
}
// (2) Run each before-interceptor in order; the FIRST deny short-circuits.
for entry in &chain.before {
let resolved = match entry {
// A guard is declared but its script is missing/disabled — fail closed.
InterceptorEntry::Dangling(name) => {
return Err(runtime_err(&format!(
"{service}::{op} denied: interceptor script `{name}` is missing or disabled"
)));
}
InterceptorEntry::Run(r) => &r.script,
};
// Identity cycle guard: if this interceptor's script is already on the
// run stack, running it again would recurse forever — DENY.
if CycleGuard::contains(resolved.script_id) {
/// Run ONE chain entry: fail-closed on a dangling marker, an identity cycle, a
/// missing engine back-reference, or a depth-limit breach; otherwise run the
/// sealed script and parse its verdict. Returns the allow outcome (with any
/// `data` transform) or an `Err` denying the operation.
fn run_one_hook(
ictx: &InterceptorCtx,
cx: &Arc<SdkCallCx>,
entry: &InterceptorEntry,
service: &str,
op: &str,
payload: &Json,
) -> Result<HookAllow, Box<EvalAltResult>> {
let resolved = match entry {
// A guard is declared but its script is missing/disabled — fail closed.
InterceptorEntry::Dangling(name) => {
return Err(runtime_err(&format!(
"{service}::{op} denied: interceptor cycle detected: `{}`",
resolved.name
"{service}::{op} denied: interceptor script `{name}` is missing or disabled"
)));
}
InterceptorEntry::Run(r) => r,
};
let script = &resolved.script;
// A guard is registered but the engine back-reference isn't installed (a
// bare test engine without `set_self_weak`): we cannot run it, so DENY
// a resolvable guard we can't execute must not fail open. Production
// always installs the back-ref.
let Some(self_engine) = ictx.self_engine.as_ref() else {
return Err(runtime_err(&format!(
"{service}::{op} denied: interceptor `{}` cannot run (engine back-reference not installed)",
resolved.name
)));
};
// Depth bound (shared with invoke / trigger fan-out).
if cx.trigger_depth + 1 > ictx.limits.trigger_depth_max {
return Err(runtime_err(&format!(
"{service}::{op} interceptor `{}`: depth limit exceeded (max {})",
resolved.name, ictx.limits.trigger_depth_max
)));
}
// Run the sealed script with the operation context as its request body,
// under the re-entrancy guard (its own writes bypass interception) and
// the identity cycle guard (its own script id is on the stack).
let ret = {
let _reentry = ReentryGuard::enter();
let _cycle = CycleGuard::enter(resolved.script_id);
run_resolved_blocking(
self_engine,
cx,
resolved,
payload.clone(),
&format!("{service}::{op} interceptor `{}`", resolved.name),
)?
};
// Fail-closed verdict: allow ONLY on an explicit `#{ allowed: true }`. A
// bare bool, a typo'd key, a non-bool `allowed`, a bare unit, or any
// other shape DENIES — a control whose job is to deny must not allow by
// omission.
match &ret {
// Explicit allow → this entry passes; continue to the next.
Json::Object(m) if m.get("allowed") == Some(&Json::Bool(true)) => {}
Json::Object(m) => {
let reason = m
.get("reason")
.and_then(Json::as_str)
.unwrap_or("denied by interceptor");
return Err(runtime_err(&format!(
"{service}::{op} denied by interceptor `{}`: {reason}",
resolved.name
)));
}
_ => {
return Err(runtime_err(&format!(
"{service}::{op} denied by interceptor `{}`: no allow verdict returned",
resolved.name
)));
}
}
// Identity cycle guard: if this interceptor's script is already on the run
// stack, running it again would recurse forever — DENY.
if CycleGuard::contains(script.script_id) {
return Err(runtime_err(&format!(
"{service}::{op} denied: interceptor cycle detected: `{}`",
script.name
)));
}
// Every before-interceptor allowed.
// A guard is registered but the engine back-reference isn't installed (a bare
// test engine without `set_self_weak`): we cannot run it, so DENY — a
// resolvable guard we can't execute must not fail open. Production always
// installs the back-ref.
let Some(self_engine) = ictx.self_engine.as_ref() else {
return Err(runtime_err(&format!(
"{service}::{op} denied: interceptor `{}` cannot run (engine back-reference not installed)",
script.name
)));
};
// Depth bound (shared with invoke / trigger fan-out).
if cx.trigger_depth + 1 > ictx.limits.trigger_depth_max {
return Err(runtime_err(&format!(
"{service}::{op} interceptor `{}`: depth limit exceeded (max {})",
script.name, ictx.limits.trigger_depth_max
)));
}
// Run the sealed script with the operation context as its request body, under
// the re-entrancy guard (its own writes bypass interception) and the identity
// cycle guard (its own script id is on the stack).
let ret = {
let _reentry = ReentryGuard::enter();
let _cycle = CycleGuard::enter(script.script_id);
run_resolved_blocking(
self_engine,
cx,
script,
payload.clone(),
&format!("{service}::{op} interceptor `{}`", script.name),
)?
};
// Fail-closed verdict: allow ONLY on an explicit `#{ allowed: true }`. A bare
// bool, a typo'd key, a non-bool `allowed`, a bare unit, or any other shape
// DENIES — a control whose job is to deny must not allow by omission.
match ret {
Json::Object(mut m) if m.get("allowed") == Some(&Json::Bool(true)) => Ok(HookAllow {
data: m.remove("data"),
}),
Json::Object(m) => {
let reason = m
.get("reason")
.and_then(Json::as_str)
.unwrap_or("denied by interceptor");
Err(runtime_err(&format!(
"{service}::{op} denied by interceptor `{}`: {reason}",
script.name
)))
}
_ => Err(runtime_err(&format!(
"{service}::{op} denied by interceptor `{}`: no allow verdict returned",
script.name
))),
}
}
/// Cap a `data` transform at the same byte ceiling the SDK uses for a Rhai→JSON
/// materialization, so an interceptor can't rewrite a small write into an
/// unbounded blob before it reaches the service's own value cap.
fn enforce_transform_size(service: &str, op: &str, data: &Json) -> Result<(), Box<EvalAltResult>> {
let len = serde_json::to_vec(data)
.map(|v| v.len())
.unwrap_or(usize::MAX);
if len > crate::sdk::bridge::MAX_JSON_MATERIALIZE_BYTES {
return Err(runtime_err(&format!(
"{service}::{op} interceptor transform too large ({len} bytes exceeds the {}-byte limit)",
crate::sdk::bridge::MAX_JSON_MATERIALIZE_BYTES
)));
}
Ok(())
}

View File

@@ -172,9 +172,9 @@ fn register_set(engine: &mut RhaiEngine) {
"set",
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
// §9.4 before-op interceptor (allow/deny). A denial errors here and
// the write below never runs.
super::interceptor::run_before(
// §9.4 before-op interceptor (allow/deny + M4 data-transform). A
// denial errors here; a returned `data` rewrites the written value.
let write_val = super::interceptor::run_before(
&handle.ictx,
&handle.cx,
"kv",
@@ -182,11 +182,24 @@ fn register_set(engine: &mut RhaiEngine) {
&handle.collection,
key,
Some(&json),
)?;
)?
.unwrap_or(json);
let written = write_val.clone();
let h = handle.clone();
block_on("kv", async move {
h.service.set(&h.cx, &h.collection, key, json).await
})
h.service.set(&h.cx, &h.collection, key, write_val).await
})?;
// §9.4 M3 after-hook (observe/audit; the write already committed).
super::interceptor::run_after(
&handle.ictx,
&handle.cx,
"kv",
"set",
&handle.collection,
key,
Some(&written),
serde_json::Value::Null,
)
},
);
}
@@ -228,6 +241,7 @@ fn register_delete(engine: &mut RhaiEngine) {
engine.register_fn(
"delete",
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
// Delete carries no value, so the before-hook never transforms.
super::interceptor::run_before(
&handle.ictx,
&handle.cx,
@@ -238,9 +252,20 @@ fn register_delete(engine: &mut RhaiEngine) {
None,
)?;
let h = handle.clone();
block_on("kv", async move {
let was_present = block_on("kv", async move {
h.service.delete(&h.cx, &h.collection, key).await
})
})?;
super::interceptor::run_after(
&handle.ictx,
&handle.cx,
"kv",
"delete",
&handle.collection,
key,
None,
serde_json::Value::Bool(was_present),
)?;
Ok(was_present)
},
);
}
@@ -252,7 +277,7 @@ fn group_run_before(
op: &'static str,
key: &str,
value: Option<&serde_json::Value>,
) -> Result<(), Box<EvalAltResult>> {
) -> Result<Option<serde_json::Value>, Box<EvalAltResult>> {
super::interceptor::run_before(
&handle.ictx,
&handle.cx,
@@ -264,6 +289,25 @@ fn group_run_before(
)
}
fn group_run_after(
handle: &GroupKvHandle,
op: &'static str,
key: &str,
value: Option<&serde_json::Value>,
result: serde_json::Value,
) -> Result<(), Box<EvalAltResult>> {
super::interceptor::run_after(
&handle.ictx,
&handle.cx,
"kv",
op,
&handle.collection,
key,
value,
result,
)
}
fn register_list(engine: &mut RhaiEngine) {
// Zero-arg form — full page, no cursor.
engine.register_fn(
@@ -330,12 +374,15 @@ fn register_group_set(engine: &mut RhaiEngine) {
"set",
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
// §9.4 before-op interceptor also guards shared-collection writes.
group_run_before(handle, "set", key, Some(&json))?;
// §9.4 before-op interceptor also guards shared-collection writes
// (allow/deny + M4 data-transform).
let write_val = group_run_before(handle, "set", key, Some(&json))?.unwrap_or(json);
let written = write_val.clone();
let h = handle.clone();
block_on("kv", async move {
h.service.set(&h.cx, &h.collection, key, json).await
})
h.service.set(&h.cx, &h.collection, key, write_val).await
})?;
group_run_after(handle, "set", key, Some(&written), serde_json::Value::Null)
},
);
}
@@ -376,9 +423,17 @@ fn register_group_delete(engine: &mut RhaiEngine) {
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
group_run_before(handle, "delete", key, None)?;
let h = handle.clone();
block_on("kv", async move {
let was_present = block_on("kv", async move {
h.service.delete(&h.cx, &h.collection, key).await
})
})?;
group_run_after(
handle,
"delete",
key,
None,
serde_json::Value::Bool(was_present),
)?;
Ok(was_present)
},
);
}