fix(interceptors): harden the §9.4 slice — seal, fail-closed, re-entrancy, shared coverage

An end-to-end audit of the (unmerged) interceptor slice surfaced seven ways a
KV allow/deny guard could be silently defeated or misbehave. Close all of them:

1. Shared-collection bypass — `kv::shared_collection(...).set/delete` skipped
   the hook entirely, so any `(kv, set/delete)` guard was circumvented by
   choosing the shared handle. `GroupKvHandle` now runs the same before-op hook.

2. Seal to the declaring owner — the marker resolved nearest-owner-wins but its
   script name was then re-resolved on the CALLER's chain, so a descendant app
   could shadow a group's mandatory guard with a same-named local script.
   `resolve_before` now returns the script sealed to the owner that declared the
   marker (one LEFT JOIN in manager-core), so the executor never re-resolves by
   name. A descendant can only override with its OWN explicit marker.

3. Re-entrancy — an "allow + audit-log" guard that itself wrote KV re-triggered
   itself to the depth cap and then DENIED the original write (plus ~8x
   resolve/compile/execute amplification). A thread-local guard makes a write
   performed by an interceptor bypass interception.

4. Fail-closed verdict — only `#{ allowed: false }` denied; a bare bool, a
   typo'd key, a non-bool, or a bare unit all ALLOWED. Now allow ONLY on an
   explicit `#{ allowed: true }`; every other shape denies.

5/6. Fail-closed edges — a dangling (missing/disabled) interceptor script and a
   missing engine back-reference now DENY instead of allowing.

7. AppInvoke coupling — resolution no longer routes through `invoke.resolve`, so
   installing a guard no longer denies writes to principals who hold KV-write
   but not AppInvoke.

The seam (`InterceptorService::resolve_before`) now returns an
`InterceptorResolution { None | Dangling | Run(ResolvedScript) }`; the executor
runs the sealed script straight through the shared `run_resolved_blocking` core
and drops its `InvokeService` dependency. executor-core stays Postgres-free.
No migration change (0073 unchanged).

Pinned by three new journeys in tests/interceptors.rs: the seal (a same-named
app script does NOT shadow the group's guard), shared-collection coverage, and
the fail-closed verdict. Full journey suite 157/157.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 21:20:20 +02:00
parent 2fc9476f9e
commit d08df88df5
7 changed files with 504 additions and 102 deletions

View File

@@ -30,9 +30,7 @@
use std::sync::Arc;
use picloud_shared::{
GroupKvService, InterceptorService, InvokeService, KvService, SdkCallCx, Services,
};
use picloud_shared::{GroupKvService, InterceptorService, KvService, SdkCallCx, Services};
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
@@ -48,7 +46,6 @@ pub struct KvHandle {
service: Arc<dyn KvService>,
cx: Arc<SdkCallCx>,
interceptors: Arc<dyn InterceptorService>,
invoke: Arc<dyn InvokeService>,
self_engine: Option<Arc<Engine>>,
limits: Limits,
}
@@ -57,12 +54,17 @@ pub struct KvHandle {
/// Rhai type from `KvHandle` so the method set can diverge (e.g. shared
/// 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).
/// 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-
/// 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,
}
pub(super) fn register(
@@ -75,7 +77,6 @@ pub(super) fn register(
let kv_service = services.kv.clone();
let group_kv_service = services.group_kv.clone();
let interceptors = services.interceptors.clone();
let invoke = services.invoke.clone();
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
// the `kv` static module so the script-visible calls are `kv::collection`
@@ -85,7 +86,6 @@ pub(super) fn register(
let kv_service = kv_service.clone();
let cx = cx.clone();
let interceptors = interceptors.clone();
let invoke = invoke.clone();
let self_engine = self_engine.clone();
module.set_native_fn(
"collection",
@@ -98,7 +98,6 @@ pub(super) fn register(
service: kv_service.clone(),
cx: cx.clone(),
interceptors: interceptors.clone(),
invoke: invoke.clone(),
self_engine: self_engine.clone(),
limits,
})
@@ -108,6 +107,8 @@ 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();
module.set_native_fn(
"shared_collection",
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
@@ -118,6 +119,9 @@ 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,
})
},
);
@@ -180,7 +184,6 @@ fn register_set(engine: &mut RhaiEngine) {
// the write below never runs.
super::interceptor::run_before(
&handle.interceptors,
&handle.invoke,
handle.self_engine.as_ref(),
&handle.cx,
handle.limits,
@@ -237,7 +240,6 @@ fn register_delete(engine: &mut RhaiEngine) {
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
super::interceptor::run_before(
&handle.interceptors,
&handle.invoke,
handle.self_engine.as_ref(),
&handle.cx,
handle.limits,
@@ -255,6 +257,27 @@ fn register_delete(engine: &mut RhaiEngine) {
);
}
/// §9.4: run the before-op interceptor for a shared-collection write, so a
/// `(kv, set/delete)` guard covers the shared handle too (not just private KV).
fn group_run_before(
handle: &GroupKvHandle,
op: &'static str,
key: &str,
value: Option<&serde_json::Value>,
) -> Result<(), Box<EvalAltResult>> {
super::interceptor::run_before(
&handle.interceptors,
handle.self_engine.as_ref(),
&handle.cx,
handle.limits,
"kv",
op,
&handle.collection,
key,
value,
)
}
fn register_list(engine: &mut RhaiEngine) {
// Zero-arg form — full page, no cursor.
engine.register_fn(
@@ -320,8 +343,10 @@ fn register_group_set(engine: &mut RhaiEngine) {
engine.register_fn(
"set",
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
let h = handle.clone();
let json = dynamic_to_json(&value);
// §9.4 before-op interceptor also guards shared-collection writes.
group_run_before(handle, "set", key, Some(&json))?;
let h = handle.clone();
block_on("kv", async move {
h.service.set(&h.cx, &h.collection, key, json).await
})
@@ -363,6 +388,7 @@ fn register_group_delete(engine: &mut RhaiEngine) {
engine.register_fn(
"delete",
|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 {
h.service.delete(&h.cx, &h.collection, key).await