Skip to main content

cdp_client/
webmcp_guard.rs

1use super::{transport_error, Cdp, DOCUMENT_CHECK, OPTIONAL_STEP};
2use serde_json::{json, Value};
3use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::time::{Duration, Instant};
5
6const SHIM: &str = include_str!("webmcp-shim.js");
7const SHIM_HASH: &str = "911b9c665b7bf8183de1d4eeebb4a96d72ddcfe7b6b222316998c595b410fe53";
8const GATE: &str = "__slicc_webmcp_gate";
9const PERMIT: &str = "__slicc_webmcp_admitted";
10
11fn vetted_source(source: &str) -> Result<&str, String> {
12    if crate::hash::sha256_hex(source.as_bytes()) != SHIM_HASH { return Err("slicc: refused: WebMCP shim integrity check failed\n".into()); }
13    Ok(source)
14}
15
16fn private_bridge(fill: impl FnOnce(&mut [u8]) -> Result<(), getrandom::Error>) -> Result<String, String> {
17    let mut bytes = [0; 16];
18    fill(&mut bytes).map_err(|_| "slicc: refused: WebMCP bridge entropy unavailable\n")?;
19    Ok(format!("__wmcp_{}", bytes.iter().map(|byte| format!("{byte:02x}")).collect::<String>()))
20}
21
22struct Arm {
23    source: String,
24    bridge: String,
25    identifier: Option<String>,
26    admitted: usize,
27    revoked: bool,
28    revoked_at: Option<(&'static str, u32)>,
29    revoked_reason: Option<&'static str>,
30    pending_contexts: BTreeMap<i64, Instant>,
31    pending_install: Option<Instant>,
32    pending_frames: BTreeMap<String, (String, Instant)>,
33    accepted: BTreeSet<(String, String, String)>,
34    loaders: BTreeMap<String, String>,
35    documents: BTreeMap<String, Value>,
36}
37
38#[derive(Clone, PartialEq)]
39struct Context {
40    frame: String,
41    origin: String,
42    unique: String,
43    loader: String,
44}
45
46#[derive(Default)]
47pub(super) struct Webmcp {
48    arms: BTreeMap<String, Arm>,
49    contexts: BTreeMap<(String, i64), Context>,
50    scripts: BTreeMap<(String, String), i64>,
51    paused: BTreeSet<String>,
52    handling_gate: bool,
53    installing_children: BTreeSet<String>,
54    pending_gates: VecDeque<(Value, Option<(i64, Context)>)>,
55    pub(super) deferred: BTreeSet<String>,
56    pub(super) quarantined: BTreeSet<String>,
57    parents: BTreeMap<String, String>,
58    children: Vec<(String, String, String)>,
59    samples: Vec<u128>,
60    trace_sequence: u64,
61    trace_lifecycle: BTreeMap<(String, &'static str), u64>,
62    trace_requests: BTreeMap<(String, String), (String, u64)>,
63    trace_fetch: BTreeMap<String, Value>,
64    trace_roles: BTreeMap<String, &'static str>,
65}
66
67impl Webmcp {
68    pub(super) fn forget(&mut self, session: &str) {
69        self.arms.remove(session);
70        self.contexts.retain(|(owner, _), _| owner != session);
71        self.scripts.retain(|(owner, _), _| owner != session);
72        self.paused.remove(session);
73        self.installing_children.remove(session);
74        self.pending_gates.retain(|(value, _)| value.get("sessionId").and_then(Value::as_str) != Some(session) && value.pointer("/params/sessionId").and_then(Value::as_str) != Some(session));
75        self.deferred.remove(session);
76        self.quarantined.remove(session);
77        self.parents.retain(|child, parent| child != session && parent != session);
78        self.children.retain(|(_, owner, _)| owner != session);
79    }
80}
81
82impl Cdp {
83    pub fn start_webmcp(&mut self, session: &str) -> Result<String, String> {
84        self.admit("Runtime.enable", &json!({}), Some(session))?;
85        let tree = self.webmcp_stable(session, Instant::now() + DOCUMENT_CHECK, |cdp, left| {
86            let target = cdp.session_target(session).ok_or_else(|| cdp.refusal())?;
87            let guard = cdp.guards.get(&target).cloned().ok_or_else(|| cdp.refusal())?;
88            let tree = cdp.raw_call("Page.getFrameTree", json!({}), Some(&guard), left.min(OPTIONAL_STEP))?;
89            if !cdp.webmcp_tree_verified(&tree, None) || cdp.content_blocked(session) || cdp.unobserved.contains(&target) {
90                return Err(cdp.refusal());
91            }
92            Ok(tree)
93        })?;
94        self.webmcp_install(session, false, Some(&tree))
95    }
96
97    fn webmcp_stable<T>(&mut self, session: &str, deadline: Instant, mut attempt: impl FnMut(&mut Self, Duration) -> Result<T, String>) -> Result<T, String> {
98        loop {
99            let left = deadline.saturating_duration_since(Instant::now());
100            if left.is_zero() || self.blocked.contains(session) || self.webmcp.arms.get(session).is_some_and(|arm| arm.revoked) { return Err(self.refusal()); }
101            let epoch = self.frame_epoch;
102            let result = attempt(self, left);
103            if result.as_ref().is_err_and(|message| epoch != self.frame_epoch && !transport_error(message)) { continue; }
104            return result;
105        }
106    }
107
108    pub fn webmcp_auto_attach(&mut self, session: &str) -> Result<(), String> {
109        if !self.webmcp.arms.contains_key(session) || self.content_blocked(session) {
110            return Err(self.refusal());
111        }
112        self.raw_call("Target.setAutoAttach", json!({"autoAttach":true,"waitForDebuggerOnStart":true,"flatten":true,"filter":[{"type":"iframe","exclude":false}]}), Some(session), OPTIONAL_STEP)?;
113        Ok(())
114    }
115
116    pub fn take_webmcp_children(&mut self) -> Vec<(String, String, String)> {
117        std::mem::take(&mut self.webmcp.children)
118    }
119
120    pub fn webmcp_pause_micros(&self) -> &[u128] {
121        &self.webmcp.samples
122    }
123
124    pub fn webmcp_document(&mut self, session: &str, frame: &str) -> Result<Value, String> {
125        self.webmcp_stable(session, Instant::now() + DOCUMENT_CHECK, |cdp, left| cdp.webmcp_document_once(session, frame, left))
126    }
127
128    fn webmcp_document_once(&mut self, session: &str, frame: &str, left: Duration) -> Result<Value, String> {
129        if self.content_blocked(session) { return Err(self.refusal()); }
130        let target = self.session_target(session).ok_or_else(|| self.refusal())?;
131        let guard = self.guards.get(&target).cloned().ok_or_else(|| self.refusal())?;
132        let epoch = self.frame_epoch;
133        let tree = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), left.min(OPTIONAL_STEP))?;
134        if epoch != self.frame_epoch || !self.webmcp_tree_verified(&tree, Some(frame)) || self.content_blocked(session) { return Err(self.refusal()); }
135        let document = Self::webmcp_frame(&tree["frameTree"], frame).ok_or_else(|| self.refusal())?;
136        Ok(json!({"frameId":frame,"loaderId":document["loaderId"],"origin":document["securityOrigin"],"parentFrameId":document.get("parentId")}))
137    }
138
139    pub fn webmcp_snapshot(&self) -> Vec<Value> {
140        self.webmcp.arms.iter().filter(|(session, arm)| !arm.revoked && !self.webmcp.quarantined.contains(*session) && !self.content_blocked(session)).map(|(session, arm)| {
141            json!({"session":session,"bridge":arm.bridge,"target":self.session_target(session),"documents":arm.documents})
142        }).collect()
143    }
144
145    pub fn webmcp_alive(&self, session: &str) -> bool {
146        self.webmcp.arms.get(session).is_some_and(|arm| !arm.revoked) && !self.content_blocked(session)
147    }
148
149    pub fn webmcp_settled(&self) -> bool {
150        self.webmcp.arms.iter().all(|(session, arm)| {
151            let quarantined = self.webmcp.quarantined.contains(session);
152            arm.revoked || self.blocked.contains(session) || !quarantined && (self.content_blocked(session)
153                || arm.pending_install.is_none() && arm.pending_contexts.is_empty() && arm.pending_frames.is_empty())
154        })
155    }
156
157    pub fn webmcp_generations(&self) -> Vec<Value> {
158        let mut generations = Vec::new();
159        for (session, arm, target) in self.webmcp.arms.iter().filter_map(|(session, arm)| self.session_target(session).map(|target| (session, arm, target))) {
160            let document = self.documents.get(&target).is_some_and(|doc| !doc.navigating && !doc.probed && !doc.previous && doc.unreachable_url.is_empty());
161            let live = document && !arm.revoked && !self.webmcp.quarantined.contains(session) && !self.content_blocked(session) && !self.unobserved.contains(&target);
162            let admitted = |context: &&Context| arm.loaders.get(&context.frame) == Some(&context.loader) && !arm.pending_frames.contains_key(&context.frame)
163                && !self.frame_protected(&context.frame) && !self.guard.url(&context.origin) && arm.accepted.contains(&(context.frame.clone(), context.loader.clone(), context.unique.clone()));
164            let contexts = self.webmcp.contexts.iter().filter(|((owner, _), _)| live && owner == session).map(|(_, context)| context).filter(admitted);
165            generations.extend(contexts.map(|context| json!({"session":session,"target":target,"frameId":context.frame,"loaderId":context.loader,"uniqueId":context.unique,"origin":context.origin,"bridge":arm.bridge})));
166        }
167        generations
168    }
169
170    pub(super) fn webmcp_sweep(&mut self) {
171        let now = Instant::now();
172        let stale = self.webmcp.arms.iter().filter(|(_, arm)| !arm.revoked && (arm.pending_install.is_some_and(|deadline| deadline <= now) || arm.pending_contexts.values().any(|deadline| *deadline <= now) || arm.pending_frames.values().any(|(_, deadline)| *deadline <= now))).map(|(session, _)| session.clone()).collect::<Vec<_>>();
173        for session in stale {
174            let reason = self.webmcp.arms.get(&session).map(|arm| if arm.pending_install.is_some_and(|deadline| deadline <= now) { "installer deadline" } else if arm.pending_contexts.values().any(|deadline| *deadline <= now) { "context deadline" } else { "frame deadline" }).unwrap_or("missing holder");
175            self.webmcp_revoke_because(&session, reason);
176            let _ = self.send("Debugger.resume", json!({}), Some(&session));
177        }
178    }
179
180    pub fn stop_webmcp(&mut self, session: &str) {
181        self.webmcp_revoke(session);
182        let _ = self.raw_call("Target.setAutoAttach", json!({"autoAttach":false,"waitForDebuggerOnStart":false,"flatten":true}), Some(session), Duration::from_secs(2));
183        let _ = self.raw_call("Debugger.resume", json!({}), Some(session), Duration::from_secs(2));
184        self.webmcp.arms.remove(session);
185    }
186
187    fn webmcp_install(&mut self, session: &str, pending: bool, seed: Option<&Value>) -> Result<String, String> {
188        let shim = vetted_source(SHIM)?;
189        let bridge = private_bridge(getrandom::fill)?;
190        let source = format!("const {bridge} = (function {GATE}() {{ 'use strict'; let {PERMIT} = false; debugger; if (!{PERMIT}) return; return {shim}; }})();");
191        let mut loaders = BTreeMap::new();
192        if let Some(tree) = seed { Self::webmcp_loaders(&tree["frameTree"], &mut loaders); }
193        self.webmcp.arms.insert(session.to_string(), Arm { source: source.clone(), bridge: bridge.clone(), identifier: None, admitted: 0, revoked: false, revoked_at: None, revoked_reason: None, pending_contexts: BTreeMap::new(), pending_install: pending.then(|| Instant::now() + Duration::from_secs(2)), pending_frames: BTreeMap::new(), accepted: BTreeSet::new(), loaders, documents: BTreeMap::new() });
194        self.raw_call("Runtime.disable", json!({}), Some(session), OPTIONAL_STEP)?;
195        self.raw_call("Runtime.enable", json!({}), Some(session), OPTIONAL_STEP)?;
196        self.worlds.enable(session);
197        self.raw_call("Page.enable", json!({}), Some(session), OPTIONAL_STEP)?;
198        self.raw_call("Network.enable", json!({}), Some(session), OPTIONAL_STEP)?;
199        self.raw_call("Debugger.enable", json!({}), Some(session), OPTIONAL_STEP)?;
200        let added = self.raw_call("Page.addScriptToEvaluateOnNewDocument", json!({"source":source,"runImmediately":seed.is_some()}), Some(session), OPTIONAL_STEP);
201        let identifier = added.ok().and_then(|result| result.get("identifier").and_then(Value::as_str).map(str::to_string));
202        let Some(identifier) = identifier else {
203            self.webmcp_revoke(session);
204            self.block(session);
205            return Err(self.refusal());
206        };
207        if let Some(arm) = self.webmcp.arms.get_mut(session) {
208            arm.identifier = Some(identifier);
209        }
210        if !pending {
211            let until = Instant::now() + Duration::from_secs(2);
212            while self.webmcp.arms.get(session).is_some_and(|arm| (arm.admitted == 0 || !arm.pending_contexts.is_empty()) && !arm.revoked) && Instant::now() < until {
213                if !self.pump(until)? { break; }
214            }
215            if self.webmcp.arms.get(session).is_none_or(|arm| arm.admitted == 0 || !arm.pending_contexts.is_empty() || arm.revoked) {
216                self.webmcp_revoke(session);
217                let _ = self.raw_call("Debugger.resume", json!({}), Some(session), Duration::from_secs(2));
218                return Err(self.refusal());
219            }
220        }
221        if self.webmcp.arms.get(session).is_some_and(|arm| arm.revoked) {
222            self.webmcp_revoke(session);
223            return Err(self.refusal());
224        }
225        Ok(bridge)
226    }
227
228    pub(super) fn webmcp_is_paused(&self, session: &str) -> bool {
229        self.webmcp.paused.contains(session)
230    }
231
232    #[track_caller]
233    pub(super) fn webmcp_revoke(&mut self, session: &str) {
234        self.webmcp_revoke_because(session, "guard or lifecycle refusal");
235    }
236
237    #[track_caller]
238    fn webmcp_revoke_because(&mut self, session: &str, reason: &'static str) {
239        let location = std::panic::Location::caller();
240        if let Some(arm) = self.webmcp.arms.get_mut(session) {
241            if arm.revoked_at.is_none() { arm.revoked_at = Some((location.file(), location.line())); arm.revoked_reason = Some(reason); }
242            arm.revoked = true;
243            if let Some(identifier) = arm.identifier.take() {
244                let _ = self.send("Page.removeScriptToEvaluateOnNewDocument", json!({"identifier":identifier}), Some(session));
245            }
246        }
247        self.webmcp_trace_stage(session, "revoke", reason);
248    }
249
250    pub fn webmcp_trace_result<T>(&self, session: &str, stage: &str, result: Result<T, String>) -> Result<T, String> {
251        if result.is_err() { self.webmcp_trace_stage(session, "holder-wait-refused", stage); }
252        result
253    }
254
255    pub fn webmcp_trace_stage(&self, session: &str, event: &str, reason: &str) {
256        if std::env::var("SLICC_WEBMCP_TRACE").ok().as_deref() != Some("1") { return; }
257        let arm = self.webmcp.arms.get(session);
258        let location = arm.and_then(|arm| arm.revoked_at).map(|(file, line)| format!("{}:{line}", file.rsplit('/').next().unwrap_or("guard")));
259        let child = self.webmcp.parents.contains_key(session);
260        let trace = json!({"event":event,"reason":reason,"sessionRole":if child {"child"} else {"root"},"frameRole":self.webmcp.trace_roles.get(session).copied().unwrap_or(if child {"OOPIF"} else {"unknown"}),"sequence":self.webmcp.trace_sequence,"firstReason":arm.and_then(|arm| arm.revoked_reason),"firstLocation":location,"revoked":arm.is_some_and(|arm| arm.revoked),"blocked":self.blocked.contains(session),"contentBlocked":self.content_blocked(session),"pendingInstall":arm.is_some_and(|arm| arm.pending_install.is_some()),"pendingContexts":arm.map(|arm| arm.pending_contexts.len()),"pendingFrames":arm.map(|arm| arm.pending_frames.len()),"page":self.webmcp_trace_page(session)});
261        eprintln!("webmcp-polyfill: {trace}");
262    }
263
264    fn webmcp_frame_refusal(&self, frame: &Value) -> Option<&'static str> {
265        let text = |key: &str| frame.get(key).and_then(Value::as_str).unwrap_or("");
266        if text("url").is_empty() { return Some("empty document URL"); }
267        let Ok(url) = url::Url::parse(text("url")) else { return Some("unknown document URL"); };
268        if self.guard.url(text("url")) || self.guard.url(text("securityOrigin")) { return Some("protected document"); }
269        if !matches!(url.scheme(), "http" | "https") { return Some("non-HTTP document"); }
270        if url.origin().ascii_serialization() != text("securityOrigin") { return Some("document origin mismatch"); }
271        if text("id").is_empty() { return Some("missing frame id"); }
272        if text("loaderId").is_empty() { return Some("missing document loader"); }
273        if !text("unreachableUrl").is_empty() { return Some("unreachable document"); }
274        None
275    }
276
277    fn webmcp_frame_verified(&self, frame: &Value) -> bool {
278        self.webmcp_frame_refusal(frame).is_none()
279    }
280
281    fn webmcp_tree_verified(&self, tree: &Value, frame: Option<&str>) -> bool {
282        fn find(cdp: &Cdp, node: &Value, frame: Option<&str>) -> bool {
283            let current = &node["frame"];
284            let id = current.get("id").and_then(Value::as_str).unwrap_or("");
285            if !cdp.webmcp_frame_verified(current) || cdp.frame_protected(id) { return false; }
286            if frame.is_none_or(|frame| frame == id) { return true; }
287            node.get("childFrames").and_then(Value::as_array).is_some_and(|children| children.iter().any(|child| find(cdp, child, frame)))
288        }
289        tree.get("frameTree").is_some_and(|node| find(self, node, frame))
290    }
291
292    fn webmcp_loaders(node: &Value, out: &mut BTreeMap<String, String>) {
293        if let (Some(id), Some(loader)) = (node.pointer("/frame/id").and_then(Value::as_str), node.pointer("/frame/loaderId").and_then(Value::as_str)) {
294            if !loader.is_empty() { out.insert(id.to_string(), loader.to_string()); }
295        }
296        for child in node.get("childFrames").and_then(Value::as_array).into_iter().flatten() { Self::webmcp_loaders(child, out); }
297    }
298
299    fn webmcp_frame<'a>(node: &'a Value, frame: &str) -> Option<&'a Value> {
300        if node.pointer("/frame/id").and_then(Value::as_str) == Some(frame) { return node.get("frame"); }
301        node.get("childFrames").and_then(Value::as_array)?.iter().find_map(|child| Self::webmcp_frame(child, frame))
302    }
303
304    fn webmcp_verify_pause(&mut self, session: &str, paused: &Value, deadline: Instant) -> Result<(String, u64, i64, Context), String> {
305        let call = paused.pointer("/params/callFrames/0").ok_or_else(|| self.refusal())?;
306        if call.get("functionName").and_then(Value::as_str) != Some(GATE) { return Err(self.refusal()); }
307        let script = call.pointer("/location/scriptId").and_then(Value::as_str).ok_or_else(|| self.refusal())?;
308        let context_id = *self.webmcp.scripts.get(&(session.to_string(), script.to_string())).ok_or_else(|| self.refusal())?;
309        let context = self.webmcp.contexts.get(&(session.to_string(), context_id)).cloned().ok_or_else(|| self.refusal())?;
310        let source = self.raw_call("Debugger.getScriptSource", json!({"scriptId":script}), Some(session), deadline.saturating_duration_since(Instant::now()))?;
311        if self.webmcp.arms.get(session).is_none_or(|arm| arm.revoked || source.get("scriptSource").and_then(Value::as_str) != Some(&arm.source)) {
312            return Err(self.refusal());
313        }
314        let target = self.session_target(session).ok_or_else(|| self.refusal())?;
315        let guard = self.guards.get(&target).cloned().ok_or_else(|| self.refusal())?;
316        let epoch = self.frame_epoch;
317        let mut owner = session.to_string();
318        let mut ancestors = BTreeSet::new();
319        let mut ancestor_documents = Vec::new();
320        while let Some(parent) = self.webmcp.parents.get(&owner).cloned() {
321            if !ancestors.insert(parent.clone()) || !self.webmcp_alive(&parent) {
322                return Err(self.refusal());
323            }
324            let target = self.session_target(&parent).ok_or_else(|| self.refusal())?;
325            let guard = self.guards.get(&target).cloned().ok_or_else(|| self.refusal())?;
326            if self.documents.get(&target).is_none_or(|doc| doc.navigating || doc.probed || doc.previous) {
327                return Err(self.refusal());
328            }
329            let ancestor = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()))?;
330            if !self.webmcp_tree_verified(&ancestor, None) { return Err(self.refusal()); }
331            ancestor_documents.push((parent.clone(), guard, ancestor["frameTree"]["frame"].clone()));
332            owner = parent;
333        }
334        let tree = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()))?;
335        if self.webmcp_tree_verified(&tree, Some(&context.frame)) {
336            self.main_commit(&guard, &tree["frameTree"]["frame"]);
337        }
338        let blocked = self.blocked.contains(session) || !self.webmcp.quarantined.contains(session) && self.content_blocked(session);
339        if !self.webmcp_tree_verified(&tree, Some(&context.frame)) || blocked || self.unobserved.contains(&target)
340            || self.documents.get(&target).is_none_or(|doc| doc.navigating || doc.probed || doc.previous || !doc.unreachable_url.is_empty()) {
341            return Err(self.refusal());
342        }
343        let frame = Self::webmcp_frame(&tree["frameTree"], &context.frame).ok_or_else(|| self.refusal())?;
344        if frame.get("securityOrigin").and_then(Value::as_str) != Some(&context.origin) || context.unique.is_empty()
345            || context.loader.is_empty() || frame.get("loaderId").and_then(Value::as_str) != Some(&context.loader) {
346            return Err(self.refusal());
347        }
348        let fresh = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()))?;
349        let next = Self::webmcp_frame(&fresh["frameTree"], &context.frame).ok_or_else(|| self.refusal())?;
350        if epoch != self.frame_epoch || !self.webmcp_tree_verified(&fresh, Some(&context.frame))
351            || frame.get("loaderId") != next.get("loaderId") || frame.get("url") != next.get("url")
352            || self.session_target(session).as_deref() != Some(&target) || self.guards.get(&target) != Some(&guard)
353            || self.webmcp.contexts.get(&(session.to_string(), context_id)) != Some(&context) {
354            return Err(self.refusal());
355        }
356        for (parent, guard, document) in ancestor_documents {
357            let ancestor = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()))?;
358            let current = &ancestor["frameTree"]["frame"];
359            if !self.webmcp_alive(&parent) || !self.webmcp_tree_verified(&ancestor, None) || current.get("loaderId") != document.get("loaderId") || current.get("url") != document.get("url") {
360                return Err(self.refusal());
361            }
362        }
363        if epoch != self.frame_epoch { return Err(self.refusal()); }
364        let call_id = call.get("callFrameId").and_then(Value::as_str).ok_or_else(|| self.refusal())?.to_string();
365        Ok((call_id, epoch, context_id, context))
366    }
367
368    fn webmcp_provisional_child(&self, top: &Value, frame: &Value, context: &Context) -> bool {
369        top.get("id").and_then(Value::as_str).is_some_and(|id| !id.is_empty() && frame.get("parentId").and_then(Value::as_str) == Some(id))
370            && frame.get("url").and_then(Value::as_str).is_some_and(|url| url.is_empty() || url == "about:blank")
371            && frame.get("unreachableUrl").and_then(Value::as_str).is_none_or(|url| url.is_empty())
372            && frame.get("securityOrigin").and_then(Value::as_str).is_some_and(|origin| origin != context.origin && !self.guard.url(origin))
373            && !self.guard.url(&context.origin)
374    }
375
376    fn webmcp_keep_parent_installer(&mut self, session: &str, context: &Context, id: i64, deadline: Instant) -> bool {
377        let skip = |cdp: &Cdp, reason: &str| { cdp.webmcp_trace_stage(session, "parent-retention-skipped", reason); false };
378        let Some(target) = self.session_target(session) else { return skip(self, "missing target"); };
379        let Some(guard) = self.guards.get(&target).cloned() else { return skip(self, "missing guard"); };
380        if self.content_blocked(session) || self.unobserved.contains(&target) || context.frame.is_empty() || context.unique.is_empty() { return skip(self, "blocked or unknown child evidence"); }
381        let epoch = self.frame_epoch;
382        let Ok(tree) = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()).min(OPTIONAL_STEP)) else { return skip(self, "parent tree read failed"); };
383        let top = &tree["frameTree"]["frame"];
384        if self.webmcp.contexts.get(&(session.to_string(), id)) == Some(context) && Self::webmcp_frame(&tree["frameTree"], &context.frame).is_some_and(|frame| (frame.get("url").and_then(Value::as_str) == Some("about:blank") || !self.webmcp_frame_verified(frame)) && !self.webmcp_provisional_child(top, frame, context)) { return skip(self, "live blank or unverified child"); }
385        if top.get("id").and_then(Value::as_str) == Some(&context.frame) || !self.webmcp_tree_verified(&tree, None)
386            || self.documents.get(&target).is_none_or(|document| document.navigating || document.previous || document.probed || !document.unreachable_url.is_empty()) { return skip(self, "parent document not verified or unsettled"); }
387        let Some(arm) = self.webmcp.arms.get(session) else { return skip(self, "missing parent arm"); };
388        if arm.revoked || arm.identifier.is_none() || !arm.accepted.iter().any(|(frame, loader, _)| top.get("id").and_then(Value::as_str) == Some(frame) && top.get("loaderId").and_then(Value::as_str) == Some(loader)) { return skip(self, "parent current loader not admitted"); }
389        let Ok(fresh) = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()).min(OPTIONAL_STEP)) else { return skip(self, "fresh parent tree read failed"); };
390        if epoch != self.frame_epoch || Instant::now() >= deadline || fresh["frameTree"]["frame"] != *top || !self.webmcp_tree_verified(&fresh, None)
391            || self.content_blocked(session) || self.session_target(session).as_deref() != Some(&target) || self.guards.get(&target) != Some(&guard) { return skip(self, "parent changed or retention deadline"); }
392        let Some(arm) = self.webmcp.arms.get_mut(session).filter(|arm| !arm.revoked && arm.identifier.is_some()) else { return skip(self, "parent arm revoked during check"); };
393        arm.pending_contexts.remove(&id);
394        arm.pending_frames.remove(&context.frame);
395        arm.accepted.retain(|(frame, _, _)| frame != &context.frame);
396        arm.documents.retain(|_, document| document.get("frameId").and_then(Value::as_str) != Some(&context.frame) || document.get("loaderId").and_then(Value::as_str) != Some(&context.loader));
397        self.webmcp_trace_stage(session, "parent-installer-retained", "abandoned local child gate");
398        true
399    }
400
401    fn webmcp_handle_pause(&mut self, value: &Value, session: &str, child: Option<(i64, Context)>) {
402        let started = Instant::now();
403        self.webmcp.paused.insert(session.to_string());
404        let deadline = started + Duration::from_secs(2);
405        let (verified, mut admitted) = loop {
406            let epoch = self.frame_epoch;
407            let (verified, admitted) = self.webmcp_admit_pause(value, session, deadline);
408            let settled = admitted || epoch == self.frame_epoch || Instant::now() >= deadline || verified.as_ref().is_err_and(|message| transport_error(message));
409            if settled || self.blocked.contains(session) || self.webmcp.arms.get(session).is_none_or(|arm| arm.revoked) { break (verified, admitted); }
410        };
411        let kept = !admitted && verified.as_ref().is_err_and(|message| !transport_error(message)) && child.as_ref().is_some_and(|(id, context)| self.webmcp_keep_parent_installer(session, context, *id, deadline));
412        if !admitted && !kept {
413            let removable = self.webmcp.arms.get(session).is_some_and(|arm| arm.identifier.is_some());
414            let previous = self.webmcp.arms.get(session).filter(|arm| arm.revoked).and_then(|arm| arm.revoked_at)
415                .map(|(file, line)| format!(" installer revoked at {}:{line};", file.rsplit('/').next().unwrap_or("guard"))).unwrap_or_default();
416            self.webmcp_revoke_because(session, "gate not admitted");
417            let rearmed = if removable { self.webmcp_rearm_blank_child(session, value, deadline) } else { Err(self.refusal()) };
418            if let Err(message) = rearmed {
419                let stage = message.strip_prefix("slicc: refused: WebMCP blank-frame rearm ").map(str::trim).unwrap_or("unverified gate");
420                self.webmcp_trace_stage(session, "gate-refused", stage);
421                self.notices.push(format!("webmcp-polyfill: document gate refused;{previous} blank-frame rearm {stage}\n"));
422            }
423        }
424        let resumed = self.raw_call("Debugger.resume", json!({}), Some(session), Duration::from_secs(2));
425        if resumed.is_err() {
426            admitted = false;
427            self.webmcp_revoke(session);
428            if let Ok((call, _, _, _)) = &verified {
429                let _ = self.raw_call("Debugger.evaluateOnCallFrame", json!({"callFrameId":call,"expression":format!("{PERMIT} = false"),"returnByValue":true,"silent":true}), Some(session), Duration::from_secs(2));
430            }
431            let _ = self.raw_call("Debugger.resume", json!({}), Some(session), Duration::from_secs(2));
432        }
433        self.webmcp.paused.remove(session);
434        if let (true, Ok((_, _, id, context))) = (admitted, verified) {
435            if self.webmcp_mark_admitted(session, id, &context, started).is_err() { self.webmcp_revoke(session); }
436        }
437        if self.webmcp.deferred.contains(session) { self.block(session); }
438    }
439
440    fn webmcp_admit_pause(&mut self, value: &Value, session: &str, deadline: Instant) -> (Result<(String, u64, i64, Context), String>, bool) {
441        let verified = self.webmcp_verify_pause(session, value, deadline).and_then(|verified| {
442            if self.webmcp.quarantined.contains(session) {
443                self.raw_call("Target.setAutoAttach", json!({"autoAttach":true,"waitForDebuggerOnStart":true,"flatten":true,"filter":[{"type":"iframe","exclude":false}]}), Some(session), deadline.saturating_duration_since(Instant::now()))?;
444            }
445            Ok(verified)
446        });
447        let mut admitted = false;
448        if let Ok((call, epoch, id, context)) = &verified {
449            let result = self.raw_call("Debugger.evaluateOnCallFrame", json!({"callFrameId":call,"expression":format!("{PERMIT} = true"),"returnByValue":true,"silent":true}), Some(session), deadline.saturating_duration_since(Instant::now()));
450            let failure = result.as_ref().err().filter(|message| transport_error(message)).cloned();
451            admitted = Instant::now() < deadline && result.is_ok_and(|result| result.get("exceptionDetails").is_none() && result.pointer("/result/value").and_then(Value::as_bool) == Some(true))
452                && *epoch == self.frame_epoch && !self.blocked.contains(session)
453                && (self.webmcp.quarantined.contains(session) || !self.content_blocked(session))
454                && self.webmcp.contexts.get(&(session.to_string(), *id)) == Some(context)
455                && self.webmcp.arms.get(session).is_some_and(|arm| !arm.revoked);
456            if !admitted {
457                let _ = self.raw_call("Debugger.evaluateOnCallFrame", json!({"callFrameId":call,"expression":format!("{PERMIT} = false"),"returnByValue":true,"silent":true}), Some(session), Duration::from_secs(2));
458            }
459            if let Some(message) = failure { return (Err(message), false); }
460        }
461        (verified, admitted)
462    }
463
464    fn webmcp_mark_admitted(&mut self, session: &str, id: i64, expected: &Context, started: Instant) -> Result<(), String> {
465        let target = self.session_target(session).ok_or_else(|| self.refusal())?;
466        let context = self.webmcp.contexts.get(&(session.to_string(), id)).filter(|context| *context == expected).cloned().ok_or_else(|| self.refusal())?;
467        let arm = self.webmcp.arms.get_mut(session).ok_or_else(|| "slicc: refused: WebMCP holder disappeared\n".to_string())?;
468        arm.admitted += 1;
469        arm.pending_install = None;
470        arm.pending_contexts.remove(&id);
471        arm.pending_frames.remove(&context.frame);
472        if arm.accepted.len() >= 1024 { arm.accepted.clear(); }
473        arm.accepted.insert((context.frame, context.loader, context.unique));
474        let bridge = arm.bridge.clone();
475        self.webmcp.samples.push(started.elapsed().as_micros());
476        if self.webmcp.quarantined.remove(session) {
477            self.guard_sessions.remove(session);
478            self.webmcp.children.push((target, session.to_string(), bridge));
479        }
480        Ok(())
481    }
482
483    fn webmcp_rearm_blank_child(&mut self, session: &str, paused: &Value, deadline: Instant) -> Result<(), String> {
484        loop {
485            let epoch = self.frame_epoch;
486            let result = self.webmcp_rearm_blank_child_once(session, paused, deadline);
487            if result.as_ref().is_err_and(|message| epoch != self.frame_epoch && !transport_error(message)) && Instant::now() < deadline && !self.content_blocked(session) { continue; }
488            return result;
489        }
490    }
491
492    fn webmcp_rearm_blank_child_once(&mut self, session: &str, paused: &Value, deadline: Instant) -> Result<(), String> {
493        if self.content_blocked(session) || self.webmcp.quarantined.contains(session) { return Err(self.refusal()); }
494        let call = paused.pointer("/params/callFrames/0").ok_or_else(|| self.refusal())?;
495        if call.get("functionName").and_then(Value::as_str) != Some(GATE) { return Err(self.refusal()); }
496        let script = call.pointer("/location/scriptId").and_then(Value::as_str).ok_or_else(|| self.refusal())?;
497        let id = *self.webmcp.scripts.get(&(session.to_string(), script.to_string())).ok_or_else(|| self.refusal())?;
498        let context = self.webmcp.contexts.get(&(session.to_string(), id)).cloned().ok_or_else(|| "slicc: refused: WebMCP blank-frame rearm lost context\n".to_string())?;
499        let target = self.session_target(session).ok_or_else(|| self.refusal())?;
500        let guard = self.guards.get(&target).cloned().ok_or_else(|| self.refusal())?;
501        let epoch = self.frame_epoch;
502        let tree = self.raw_call("Page.getFrameTree", json!({}), Some(&guard), deadline.saturating_duration_since(Instant::now()))?;
503        let top = &tree["frameTree"]["frame"];
504        let child = Self::webmcp_frame(&tree["frameTree"], &context.frame).ok_or_else(|| "slicc: refused: WebMCP blank-frame rearm lost child frame\n".to_string())?;
505        let arm = self.webmcp.arms.get(session).ok_or_else(|| self.refusal())?;
506        if epoch != self.frame_epoch || Instant::now() >= deadline { return Err("slicc: refused: WebMCP blank-frame rearm changed frame epoch\n".into()); }
507        if !self.webmcp_tree_verified(&tree, None) || self.documents.get(&target).is_none_or(|document| document.navigating || document.previous || document.probed) {
508            return Err("slicc: refused: WebMCP blank-frame rearm unverified parent document\n".into());
509        }
510        if child.get("url").and_then(Value::as_str) != Some("about:blank") || child.get("unreachableUrl").and_then(Value::as_str).is_some_and(|url| !url.is_empty())
511            || child.get("parentId") != top.get("id") || child.get("securityOrigin") != top.get("securityOrigin")
512            || child.get("securityOrigin").and_then(Value::as_str) != Some(&context.origin) || context.unique.is_empty() {
513            return Err("slicc: refused: WebMCP blank-frame rearm unverified child document\n".into());
514        }
515        if !arm.accepted.iter().any(|(frame, loader, _)| Some(frame.as_str()) == top.get("id").and_then(Value::as_str) && Some(loader.as_str()) == top.get("loaderId").and_then(Value::as_str)) {
516            return Err("slicc: refused: WebMCP blank-frame rearm parent gate not admitted\n".into());
517        }
518        let source = arm.source.clone();
519        let result = self.raw_call("Page.addScriptToEvaluateOnNewDocument", json!({"source":source,"runImmediately":false}), Some(session), deadline.saturating_duration_since(Instant::now()))?;
520        let identifier = result.get("identifier").and_then(Value::as_str).ok_or_else(|| self.refusal())?.to_string();
521        let arm = self.webmcp.arms.get_mut(session).ok_or_else(|| "slicc: refused: WebMCP holder disappeared\n".to_string())?;
522        arm.identifier = Some(identifier);
523        if epoch != self.frame_epoch || Instant::now() >= deadline || self.blocked.contains(session) { self.webmcp_revoke(session); return Err(self.refusal()); }
524        let arm = self.webmcp.arms.get_mut(session).unwrap();
525        arm.revoked = false;
526        arm.pending_contexts.remove(&id);
527        arm.pending_frames.remove(&context.frame);
528        Ok(())
529    }
530
531    fn webmcp_adopt_child(&mut self, value: &Value, parent: &str) {
532        let Some(child) = value.pointer("/params/sessionId").and_then(Value::as_str) else { return };
533        let info = &value["params"]["targetInfo"];
534        let target = info.get("targetId").and_then(Value::as_str).unwrap_or("");
535        let url = info.get("url").and_then(Value::as_str).unwrap_or("");
536        let allowed = info.get("type").and_then(Value::as_str) == Some("iframe") && !target.is_empty()
537            && (url.is_empty() || url == "about:blank" || url::Url::parse(url).is_ok_and(|url| matches!(url.scheme(), "http" | "https")))
538            && !self.guard.url(url) && !self.info_protected(info, &[]) && !self.content_blocked(parent);
539        self.session_targets.insert(child.to_string(), target.to_string());
540        self.guard_sessions.insert(child.to_string());
541        self.webmcp.quarantined.insert(child.to_string());
542        self.webmcp.parents.insert(child.to_string(), parent.to_string());
543        self.targets.insert(target.to_string(), info.clone());
544        let mut stage = "target";
545        let installed = (|| -> Result<(), String> {
546            if !allowed { return Err(self.refusal()); }
547            stage = "guard setup";
548            self.touch(target)?;
549            stage = "protected target";
550            if self.is_protected_target(target) { return Err(self.refusal()); }
551            stage = "guard frame tree";
552            let guard = self.guards.get(target).cloned().ok_or_else(|| self.refusal())?;
553            let seed = self.webmcp_stable(child, Instant::now() + DOCUMENT_CHECK, |cdp, left| cdp.raw_call("Page.getFrameTree", json!({}), Some(&guard), left.min(OPTIONAL_STEP)))?;
554            let verified = self.webmcp_tree_verified(&seed, None);
555            let future_only = seed.pointer("/frameTree/frame/id").and_then(Value::as_str) == Some(target)
556                && seed.pointer("/frameTree/frame/url").and_then(Value::as_str).is_some_and(|url| url.is_empty() || url == "about:blank")
557                && seed.pointer("/frameTree/frame/unreachableUrl").and_then(Value::as_str).is_none_or(|url| url.is_empty())
558                && self.webmcp_alive(parent);
559            if !verified && !future_only { return Err(self.refusal()); }
560            stage = "pinned installer";
561            self.webmcp.installing_children.insert(child.to_string());
562            let installed = self.webmcp_install(child, true, verified.then_some(&seed));
563            self.webmcp.installing_children.remove(child);
564            installed?;
565            Ok(())
566        })().is_ok();
567        let _ = self.raw_call("Runtime.runIfWaitingForDebugger", json!({}), Some(child), Duration::from_secs(2));
568        if !installed {
569            self.notices.push(format!("webmcp-polyfill: child refused at {stage}\n"));
570            self.webmcp_revoke(child);
571            let _ = self.raw_call("Target.detachFromTarget", json!({"sessionId":child}), None, Duration::from_secs(2));
572            self.blocked.insert(child.to_string());
573        }
574    }
575
576    pub(super) fn webmcp_cancel_queued(&mut self, session: &str) {
577        let cancelled = self.webmcp.pending_gates.iter().filter(|(value, _)| value.get("sessionId").and_then(Value::as_str) == Some(session) || value.pointer("/params/sessionId").and_then(Value::as_str) == Some(session)).map(|(value, _)| value.clone()).collect::<Vec<_>>();
578        self.webmcp.pending_gates.retain(|(value, _)| value.get("sessionId").and_then(Value::as_str) != Some(session) && value.pointer("/params/sessionId").and_then(Value::as_str) != Some(session));
579        for value in cancelled {
580            let owner = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
581            if value.get("method").and_then(Value::as_str) == Some("Debugger.paused") {
582                let _ = self.send("Debugger.resume", json!({}), Some(owner));
583            } else if let Some(child) = value.pointer("/params/sessionId").and_then(Value::as_str) {
584                self.webmcp_revoke(child);
585                let _ = self.send("Runtime.runIfWaitingForDebugger", json!({}), Some(child));
586                let _ = self.send("Debugger.resume", json!({}), Some(child));
587                let _ = self.send("Target.detachFromTarget", json!({"sessionId":child}), None);
588            }
589        }
590    }
591
592    fn webmcp_gate_event(&mut self, value: &Value) {
593        if value.get("method").and_then(Value::as_str) == Some("Debugger.paused") {
594            if let Some(session) = value.get("sessionId").and_then(Value::as_str) { self.webmcp.paused.insert(session.to_string()); }
595        }
596        let owner = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
597        let context = value.pointer("/params/callFrames/0/location/scriptId").and_then(Value::as_str).and_then(|script| self.webmcp.scripts.get(&(owner.to_string(), script.to_string()))).and_then(|id| self.webmcp.contexts.get(&(owner.to_string(), *id)).cloned().map(|context| (*id, context)));
598        if self.webmcp.handling_gate && self.webmcp.installing_children.contains(owner) && value.get("method").and_then(Value::as_str) == Some("Debugger.paused") {
599            self.webmcp_handle_pause(value, owner, context);
600            return;
601        }
602        self.webmcp.pending_gates.push_back((value.clone(), context));
603        if self.webmcp.handling_gate { return; }
604        self.webmcp.handling_gate = true;
605        while let Some((gate, context)) = self.webmcp.pending_gates.pop_front() {
606            let owner = gate.get("sessionId").and_then(Value::as_str).unwrap_or("");
607            if gate.get("method").and_then(Value::as_str) == Some("Target.attachedToTarget") {
608                self.webmcp_adopt_child(&gate, owner);
609            } else {
610                self.webmcp_handle_pause(&gate, owner, context);
611            }
612        }
613        self.webmcp.handling_gate = false;
614    }
615
616    fn webmcp_trace_scheme(value: &str) -> &'static str {
617        if value.is_empty() { return "empty"; }
618        let Ok(url) = url::Url::parse(value) else { return "invalid"; };
619        match (url.scheme(), url.path()) {
620            ("about", "blank") => "about:blank",
621            ("about", "srcdoc") => "about:srcdoc",
622            ("about", _) => "about:other",
623            ("chrome-error", _) => "chrome-error",
624            ("http", _) => "http",
625            ("https", _) => "https",
626            ("data", _) => "data",
627            ("blob", _) => "blob",
628            ("file", _) => "file",
629            ("chrome", _) => "chrome",
630            ("chrome-extension", _) => "extension",
631            _ => "other",
632        }
633    }
634
635    fn webmcp_trace_frame(&self, frame: &Value) -> Value {
636        let text = |key| frame.get(key).and_then(Value::as_str).unwrap_or("");
637        json!({"scheme":Self::webmcp_trace_scheme(text("url")),"parentIdPresent":frame.get("parentId").is_some(),"unreachableUrlPresent":frame.get("unreachableUrl").is_some(),"unreachableUrlNonempty":!text("unreachableUrl").is_empty(),"loaderPresent":!text("loaderId").is_empty(),"refusal":self.webmcp_frame_refusal(frame)})
638    }
639
640    fn webmcp_trace_order(&self, target: &str) -> Value {
641        let mut order = serde_json::Map::new();
642        for method in ["attachedToTarget", "detachedFromTarget", "targetInfoChanged"] {
643            order.insert(method.into(), json!(self.webmcp.trace_lifecycle.get(&(target.to_string(), method))));
644        }
645        Value::Object(order)
646    }
647
648    pub(super) fn webmcp_trace_fetch_command(&mut self, method: &str, params: &Value, session: Option<&str>) {
649        let decision = match method { "Fetch.continueRequest" => "continue", "Fetch.failRequest" => "fail", "Fetch.fulfillRequest" => "fulfill", _ => return };
650        let Some(request) = params.get("requestId").and_then(Value::as_str) else { return; };
651        let Some((frame, sequence)) = self.webmcp.trace_requests.remove(&(session.unwrap_or("").to_string(), request.to_string())) else { return; };
652        let reason = params.get("errorReason").and_then(Value::as_str).map(|reason| match reason { "BlockedByClient" => "BlockedByClient", "Aborted" => "Aborted", "TimedOut" => "TimedOut", _ => "other" });
653        if let Some(record) = self.webmcp.trace_fetch.get_mut(&frame).filter(|record| record.get("pauseSequence").and_then(Value::as_u64) == Some(sequence)) { record["decision"] = json!(decision); record["errorReason"] = json!(reason); record["decisionSequence"] = json!(self.webmcp.trace_sequence); }
654    }
655
656    fn webmcp_trace_page(&self, session: &str) -> &'static str {
657        let mut root = session;
658        let mut visited = BTreeSet::new();
659        while let Some(parent) = self.webmcp.parents.get(root) {
660            if !visited.insert(root) { return "unknown"; }
661            root = parent;
662        }
663        let url = self.session_target(root).and_then(|target| self.documents.get(&target)).map(|document| document.url.as_str()).unwrap_or("");
664        match url::Url::parse(url).ok().map(|url| url.path().to_string()).as_deref() { Some("/frames") => "frames", Some("/ordinary-frames") => "ordinary-frames", Some(_) => "other", None => "unknown" }
665    }
666
667    fn webmcp_trace_navigation(&mut self, session: &str, frame: &Value) {
668        if std::env::var("SLICC_WEBMCP_TRACE").ok().as_deref() != Some("1") { return; }
669        let sequence = self.webmcp.trace_sequence;
670        let page = self.webmcp_trace_page(session);
671        let target = self.session_target(session).unwrap_or_default();
672        let kind = self.targets.get(&target).and_then(|info| info.get("type")).and_then(Value::as_str).unwrap_or("");
673        let kind = match kind { "page" => "page", "iframe" => "iframe", "worker" => "worker", "" => "unknown", _ => "other" };
674        let before = self.webmcp_trace_order(&target);
675        let epoch = self.frame_epoch;
676        let tree = self.guards.get(&target).cloned().and_then(|guard| self.raw_exchange("Page.getFrameTree", json!({}), Some(&guard), OPTIONAL_STEP, false).ok());
677        let id = frame.get("id").and_then(Value::as_str).unwrap_or("");
678        let current = tree.as_ref().and_then(|tree| tree.get("frameTree")).and_then(|node| Self::webmcp_frame(node, id));
679        let current_metadata = current.map(|current| self.webmcp_trace_frame(current));
680        let loader_equal = current.map(|current| current.get("loaderId").and_then(Value::as_str).filter(|loader| !loader.is_empty()) == frame.get("loaderId").and_then(Value::as_str).filter(|loader| !loader.is_empty()));
681        let trace = json!({"event":"guard-navigation-trace","sequence":sequence,"targetType":kind,"eventFrame":self.webmcp_trace_frame(frame),"guardTreeAvailable":tree.is_some(),"frameInGuardTree":current.is_some(),"guardFrame":current_metadata,"loaderEqual":loader_equal,"frameEpochStable":epoch == self.frame_epoch,"lifecycleBefore":before,"lifecycleAfter":self.webmcp_trace_order(&target),"probeEndSequence":self.webmcp.trace_sequence,"documentFetch":self.webmcp.trace_fetch.get(id),"page":page,"pageAfter":self.webmcp_trace_page(session),"unreachableProtected":frame.get("unreachableUrl").and_then(Value::as_str).is_some_and(|url| !url.is_empty() && self.guard.url(url)),"targetURLProtected":self.targets.get(&target).and_then(|info| info.get("url")).and_then(Value::as_str).is_some_and(|url| self.guard.url(url))});
682        self.notices.push(format!("webmcp-polyfill: {trace}\n"));
683    }
684
685    fn webmcp_trace_event(&mut self, value: &Value) {
686        if std::env::var("SLICC_WEBMCP_TRACE").ok().as_deref() != Some("1") { return; }
687        self.webmcp.trace_sequence += 1;
688        let session = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
689        let method = value.get("method").and_then(Value::as_str).unwrap_or("");
690        if method == "Page.frameNavigated" || method == "Debugger.paused" {
691            let frame = if method == "Page.frameNavigated" { value.pointer("/params/frame/id").and_then(Value::as_str) } else { value.pointer("/params/callFrames/0/location/scriptId").and_then(Value::as_str).and_then(|script| self.webmcp.scripts.get(&(session.to_string(), script.to_string()))).and_then(|id| self.webmcp.contexts.get(&(session.to_string(), *id))).map(|context| context.frame.as_str()) };
692            let top = self.session_target(session).and_then(|target| self.documents.get(&target)).map(|doc| doc.frame_id.as_str());
693            let role = if self.webmcp.parents.contains_key(session) { "OOPIF" } else if frame.is_none() { "unknown" } else if frame == top { "top" } else { "subframe" };
694            self.webmcp.trace_roles.insert(session.to_string(), role);
695        }
696        let lifecycle = match method { "Target.attachedToTarget" => Some("attachedToTarget"), "Target.detachedFromTarget" => Some("detachedFromTarget"), "Target.targetInfoChanged" => Some("targetInfoChanged"), _ => None };
697        if method == "Fetch.requestPaused" && value.pointer("/params/resourceType").and_then(Value::as_str) == Some("Document") {
698            if let (Some(request), Some(frame)) = (value.pointer("/params/requestId").and_then(Value::as_str), value.pointer("/params/frameId").and_then(Value::as_str)) {
699                if self.webmcp.trace_requests.len() >= 1024 || self.webmcp.trace_fetch.len() >= 1024 { self.webmcp.trace_requests.clear(); self.webmcp.trace_fetch.clear(); }
700                self.webmcp.trace_requests.insert((session.to_string(), request.to_string()), (frame.to_string(), self.webmcp.trace_sequence));
701                let protected = value.pointer("/params/request/url").and_then(Value::as_str).is_some_and(|url| self.guard.url(url));
702                self.webmcp.trace_fetch.insert(frame.to_string(), json!({"pauseSequence":self.webmcp.trace_sequence,"decision":"pending","destinationProtected":protected,"guardSession":self.guard_sessions.contains(session)}));
703            }
704        }
705        if let Some(lifecycle) = lifecycle {
706            let target = value.pointer("/params/targetInfo/targetId").or_else(|| value.pointer("/params/targetId")).and_then(Value::as_str).map(str::to_string)
707                .or_else(|| value.pointer("/params/sessionId").and_then(Value::as_str).and_then(|session| self.session_target(session))).unwrap_or_default();
708            if self.webmcp.trace_lifecycle.len() >= 1024 { self.webmcp.trace_lifecycle.clear(); }
709            self.webmcp.trace_lifecycle.insert((target, lifecycle), self.webmcp.trace_sequence);
710        }
711    }
712
713    pub(super) fn webmcp_event(&mut self, value: &Value) -> bool {
714        self.webmcp_trace_event(value);
715        let session = value.get("sessionId").and_then(Value::as_str).unwrap_or("");
716        let method = value.get("method").and_then(Value::as_str).unwrap_or("");
717        if method == "Target.attachedToTarget" && self.webmcp.arms.contains_key(session) {
718            self.webmcp_gate_event(value);
719            return true;
720        }
721        if matches!(method, "Page.frameDetached" | "Runtime.executionContextCreated" | "Runtime.executionContextDestroyed" | "Runtime.executionContextsCleared" | "Debugger.scriptParsed") {
722            self.webmcp_context_event(value, session, method);
723            return false;
724        }
725        if !self.webmcp.arms.contains_key(session) { return false; }
726        if method == "Network.responseReceived" && value.pointer("/params/type").and_then(Value::as_str) == Some("Document") {
727            let params = &value["params"];
728            let url = params.pointer("/response/url").and_then(Value::as_str).unwrap_or("");
729            let frame = params.get("frameId").and_then(Value::as_str).unwrap_or("");
730            let loader = params.get("loaderId").and_then(Value::as_str).unwrap_or("");
731            if !frame.is_empty() && !loader.is_empty() && !self.guard.url(url) {
732                let policy = params.pointer("/response/headers").and_then(Value::as_object).and_then(|headers| headers.iter().find(|(name, _)| name.eq_ignore_ascii_case("permissions-policy"))).map(|(_, value)| value.as_str().unwrap_or("tools=()")).unwrap_or("");
733                let policy = if policy.len() > 16384 { "tools=()" } else { policy };
734                let origin = url::Url::parse(url).map(|url| url.origin().ascii_serialization()).unwrap_or_default();
735                if let Some(arm) = self.webmcp.arms.get_mut(session) {
736                    arm.documents.insert(format!("{frame}:{loader}"), json!({"frameId":frame,"loaderId":loader,"origin":origin,"policy":policy}));
737                }
738            }
739        }
740        if method == "Page.frameNavigated" {
741            let frame = &value["params"]["frame"];
742            let initial_child = (frame.get("parentId").is_some() || self.webmcp.parents.contains_key(session))
743                && frame.get("url").and_then(Value::as_str).is_some_and(|url| url.is_empty() || url == "about:blank")
744                && frame.get("unreachableUrl").and_then(Value::as_str).is_none_or(|url| url.is_empty());
745            if let Some(reason) = self.webmcp_frame_refusal(frame).filter(|_| !initial_child) {
746                self.webmcp_trace_navigation(session, frame);
747                let role = if self.webmcp.parents.contains_key(session) { "OOPIF" } else if frame.get("parentId").is_some() { "subframe" } else { "top frame" };
748                self.notices.push(format!("webmcp-polyfill: frame navigation refused ({role}, {reason})\n"));
749                self.webmcp_revoke_because(session, "frame navigation refused");
750            }
751            self.webmcp_navigated(session, value);
752        }
753        if method == "Debugger.paused" {
754            self.webmcp_gate_event(value);
755            return true;
756        }
757        false
758    }
759
760    fn webmcp_navigated(&mut self, session: &str, value: &Value) {
761        let Some(arm) = self.webmcp.arms.get_mut(session) else { return; };
762        let frame = &value["params"]["frame"];
763        Self::webmcp_loaders(&json!({"frame":frame}), &mut arm.loaders);
764        if let (Some(id), Some(loader)) = (frame.get("id").and_then(Value::as_str), frame.get("loaderId").and_then(Value::as_str)) {
765            let restored = value.pointer("/params/type").and_then(Value::as_str) == Some("BackForwardCacheRestore") && arm.accepted.iter().any(|(known, generation, _)| known == id && generation == loader);
766            if restored { arm.pending_frames.remove(id); }
767            else { arm.pending_frames.insert(id.to_string(), (loader.to_string(), Instant::now() + Duration::from_secs(2))); }
768        }
769    }
770
771    fn webmcp_context_event(&mut self, value: &Value, session: &str, method: &str) {
772        let Some(arm) = self.webmcp.arms.get_mut(session) else { return; };
773        if method == "Page.frameDetached" {
774            if let Some(frame) = value.pointer("/params/frameId").and_then(Value::as_str) {
775                arm.pending_frames.remove(frame);
776                arm.loaders.remove(frame);
777                for ((owner, id), context) in &self.webmcp.contexts { if owner == session && context.frame == frame { arm.pending_contexts.remove(id); } }
778                self.webmcp.contexts.retain(|(owner, _), context| owner != session || context.frame != frame);
779            }
780        }
781        if method == "Runtime.executionContextCreated" {
782            let context = &value["params"]["context"];
783            if context.pointer("/auxData/isDefault").and_then(Value::as_bool) == Some(true) {
784                if let Some(id) = context.get("id").and_then(Value::as_i64) {
785                    let frame = context.pointer("/auxData/frameId").and_then(Value::as_str).unwrap_or("");
786                    let unique = context.get("uniqueId").and_then(Value::as_str).unwrap_or("");
787                    let loader = arm.loaders.get(frame).cloned().unwrap_or_default();
788                    if !arm.accepted.contains(&(frame.to_string(), loader.clone(), unique.to_string())) {
789                        arm.pending_contexts.insert(id, Instant::now() + Duration::from_secs(2));
790                    }
791                    self.webmcp.contexts.insert((session.to_string(), id), Context {
792                        frame: frame.to_string(),
793                        origin: context.get("origin").and_then(Value::as_str).unwrap_or("").to_string(),
794                        unique: unique.to_string(),
795                        loader,
796                    });
797                }
798            }
799        }
800        if method == "Runtime.executionContextDestroyed" {
801            if let Some(id) = value.pointer("/params/executionContextId").and_then(Value::as_i64) {
802                self.webmcp.contexts.remove(&(session.to_string(), id));
803                arm.pending_contexts.remove(&id);
804            }
805        }
806        if method == "Runtime.executionContextsCleared" {
807            arm.pending_contexts.clear();
808            self.webmcp.contexts.retain(|(owner, _), _| owner != session);
809            self.webmcp.scripts.retain(|(owner, _), _| owner != session);
810        }
811        if method == "Debugger.scriptParsed" {
812            if let (Some(script), Some(context)) = (value.pointer("/params/scriptId").and_then(Value::as_str), value.pointer("/params/executionContextId").and_then(Value::as_i64)) {
813                self.webmcp.scripts.insert((session.to_string(), script.to_string()), context);
814            }
815        }
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    include!("webmcp_guard_test.rs");
822}