Skip to main content

glass/
extensions.rs

1//! Validated extension metadata and least-privilege permissions.
2//!
3//! The host executes one declared entrypoint request in a bounded subprocess.
4//! Native-code sandboxing and integration with guarded browser operations remain
5//! separate release gates; the `extensions` capability stays disabled until
6//! those guarantees are present.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::BTreeMap;
11use std::fmt;
12use std::path::{Component, Path, PathBuf};
13use std::sync::Arc;
14use std::time::Duration;
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::process::Command;
17use tokio::sync::Semaphore;
18
19const MAX_EXTENSION_MANIFESTS: usize = 128;
20const MAX_EXTENSION_MESSAGE_BYTES: usize = 256 * 1024;
21const MAX_EXTENSION_CONCURRENT_INVOCATIONS: usize = 4;
22const EXTENSION_TIMEOUT: Duration = Duration::from_secs(5);
23const MAX_EXTENSION_VALUE_DEPTH: usize = 8;
24const MAX_EXTENSION_VALUE_NODES: usize = 256;
25const MAX_EXTENSION_STRING_BYTES: usize = 4_096;
26
27/// Version of the extension manifest contract.
28pub const EXTENSION_SCHEMA_VERSION: u32 = 1;
29
30/// Native process sandbox available for an extension invocation.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ExtensionSandbox {
33    LinuxBubblewrap,
34    MacSandboxExec,
35    Unavailable,
36}
37
38impl ExtensionSandbox {
39    /// Detect a supported native sandbox without claiming that one exists.
40    pub fn detect() -> Self {
41        #[cfg(target_os = "linux")]
42        {
43            if std::process::Command::new("bwrap")
44                .arg("--version")
45                .stdout(std::process::Stdio::null())
46                .stderr(std::process::Stdio::null())
47                .status()
48                .is_ok_and(|status| status.success())
49            {
50                return Self::LinuxBubblewrap;
51            }
52        }
53        #[cfg(target_os = "macos")]
54        {
55            if Path::new("/usr/bin/sandbox-exec").is_file() {
56                return Self::MacSandboxExec;
57            }
58        }
59        Self::Unavailable
60    }
61
62    pub fn label(self) -> &'static str {
63        match self {
64            Self::LinuxBubblewrap => "linux-bubblewrap",
65            Self::MacSandboxExec => "macos-sandbox-exec",
66            Self::Unavailable => "unavailable",
67        }
68    }
69}
70
71/// The only target currently approved for the experimental extension gate.
72pub(crate) fn experimental_extension_target_supported() -> bool {
73    cfg!(all(target_os = "linux", target_arch = "aarch64"))
74}
75
76/// Extension points that can be negotiated without granting raw browser access.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub enum ExtensionCapability {
80    CustomVerification,
81    SemanticRegionClassifier,
82    IntentEvidencePack,
83    ExtractionTransform,
84    SiteAdapter,
85    WorkflowTemplate,
86    TraceExporter,
87    KnowledgeStore,
88    StrictPolicy,
89}
90
91/// Least-privilege host and action permissions.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94pub struct ExtensionPermissions {
95    pub hosts: Vec<String>,
96    pub actions: Vec<String>,
97}
98
99/// Versioned extension metadata. It is data, not an execution grant.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase", deny_unknown_fields)]
102pub struct ExtensionManifest {
103    pub schema_version: u32,
104    pub id: String,
105    pub version: String,
106    pub api_version: u32,
107    pub capabilities: Vec<ExtensionCapability>,
108    pub permissions: ExtensionPermissions,
109    pub entrypoint: Option<String>,
110}
111
112/// Bounded extension registry for metadata and negotiation.
113#[derive(Debug, Default)]
114pub struct ExtensionRegistry {
115    manifests: BTreeMap<String, ExtensionManifest>,
116}
117
118/// One bounded invocation sent to an extension process.
119#[derive(Debug, Clone, Serialize)]
120#[serde(rename_all = "camelCase")]
121pub struct ExtensionInvocation {
122    pub protocol_version: u32,
123    pub extension_id: String,
124    pub capability: ExtensionCapability,
125    pub host: String,
126    pub action: String,
127    pub payload: Value,
128}
129
130/// A bounded action request returned by an extension for core dispatch.
131///
132/// Extensions do not receive browser handles and cannot call CDP. The only
133/// way for an extension result to cause a browser mutation is to return this
134/// shape to [`ExtensionHost::invoke_guarded`], which requires a current
135/// observation revision and routes the operation through `BrowserSession`.
136#[derive(Debug, Clone, Deserialize)]
137#[serde(rename_all = "camelCase", deny_unknown_fields)]
138pub struct ExtensionGuardedAction {
139    pub action: String,
140    #[serde(default)]
141    pub target: Option<String>,
142    #[serde(default)]
143    pub value: Option<String>,
144    pub expected_revision: u64,
145}
146
147impl ExtensionGuardedAction {
148    fn validate(&self) -> Result<(), ExtensionError> {
149        if self.action.is_empty() || self.action.len() > 32 {
150            return Err(ExtensionError(
151                "extension guarded action name is out of bounds".into(),
152            ));
153        }
154        if self.expected_revision == 0 {
155            return Err(ExtensionError(
156                "extension guarded action requires a positive expectedRevision".into(),
157            ));
158        }
159        for (name, value, max) in [
160            ("target", self.target.as_deref(), 512),
161            ("value", self.value.as_deref(), 4_096),
162        ] {
163            if let Some(value) = value
164                && (value.is_empty() || value.len() > max)
165            {
166                return Err(ExtensionError(format!(
167                    "extension guarded action {name} is out of bounds"
168                )));
169            }
170        }
171        Ok(())
172    }
173}
174
175/// One extension process response.
176#[derive(Debug, Deserialize)]
177#[serde(rename_all = "camelCase", deny_unknown_fields)]
178struct ExtensionResponse {
179    protocol_version: u32,
180    ok: bool,
181    #[serde(default)]
182    result: Option<Value>,
183    #[serde(default)]
184    error: Option<String>,
185}
186
187/// A validated, process-isolated extension host boundary.
188#[derive(Debug)]
189pub struct ExtensionHost {
190    root: PathBuf,
191    registry: ExtensionRegistry,
192    invocations: Arc<Semaphore>,
193    experimental_enabled: bool,
194}
195
196/// Extension validation or registry failure.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct ExtensionError(pub String);
199
200impl fmt::Display for ExtensionError {
201    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
202        formatter.write_str(&self.0)
203    }
204}
205
206impl std::error::Error for ExtensionError {}
207
208impl ExtensionManifest {
209    /// Validate the manifest without loading or trusting its entrypoint.
210    pub fn validate(&self) -> Result<(), ExtensionError> {
211        const MAX_TEXT_BYTES: usize = 256;
212        const MAX_HOSTS: usize = 32;
213        const MAX_ACTIONS: usize = 32;
214        if self.schema_version != EXTENSION_SCHEMA_VERSION {
215            return Err(ExtensionError(format!(
216                "unsupported extension schema {}; expected {}",
217                self.schema_version, EXTENSION_SCHEMA_VERSION
218            )));
219        }
220        for (name, value) in [("id", self.id.as_str()), ("version", self.version.as_str())] {
221            if value.is_empty() || value.len() > MAX_TEXT_BYTES {
222                return Err(ExtensionError(format!(
223                    "extension {name} must be 1..={MAX_TEXT_BYTES} bytes"
224                )));
225            }
226        }
227        if self.api_version == 0 {
228            return Err(ExtensionError(
229                "extension apiVersion must be positive".into(),
230            ));
231        }
232        if self.capabilities.is_empty() {
233            return Err(ExtensionError(
234                "extension must declare at least one capability".into(),
235            ));
236        }
237        if self.permissions.hosts.is_empty() || self.permissions.hosts.len() > MAX_HOSTS {
238            return Err(ExtensionError(format!(
239                "extension hosts must contain 1..={MAX_HOSTS} entries"
240            )));
241        }
242        if self.permissions.actions.is_empty() || self.permissions.actions.len() > MAX_ACTIONS {
243            return Err(ExtensionError(format!(
244                "extension actions must contain 1..={MAX_ACTIONS} entries"
245            )));
246        }
247        for host in &self.permissions.hosts {
248            if host.is_empty() || host.len() > MAX_TEXT_BYTES || host == "*" || host.contains('/') {
249                return Err(ExtensionError(format!(
250                    "extension host is not an exact host: {host:?}"
251                )));
252            }
253        }
254        for action in &self.permissions.actions {
255            if !allowed_action(action) {
256                return Err(ExtensionError(format!(
257                    "extension action is not allowed: {action:?}"
258                )));
259            }
260        }
261        if let Some(entrypoint) = &self.entrypoint
262            && (entrypoint.is_empty() || entrypoint.len() > MAX_TEXT_BYTES)
263        {
264            return Err(ExtensionError(
265                "extension entrypoint is out of bounds".into(),
266            ));
267        }
268        Ok(())
269    }
270}
271
272impl ExtensionRegistry {
273    /// Register validated metadata, rejecting duplicate IDs.
274    pub fn register(&mut self, manifest: ExtensionManifest) -> Result<(), ExtensionError> {
275        manifest.validate()?;
276        if self.manifests.contains_key(&manifest.id) {
277            return Err(ExtensionError(format!(
278                "extension ID is already registered: {}",
279                manifest.id
280            )));
281        }
282        self.manifests.insert(manifest.id.clone(), manifest);
283        Ok(())
284    }
285
286    /// Return registered metadata without exposing executable state.
287    pub fn get(&self, id: &str) -> Option<&ExtensionManifest> {
288        self.manifests.get(id)
289    }
290
291    /// Return deterministic registered metadata.
292    pub fn manifests(&self) -> impl Iterator<Item = &ExtensionManifest> {
293        self.manifests.values()
294    }
295
296    /// Load JSON manifests from one directory without executing entrypoints.
297    pub fn load_dir(directory: impl AsRef<Path>) -> Result<Self, ExtensionError> {
298        let directory = directory.as_ref();
299        let metadata = std::fs::symlink_metadata(directory).map_err(|error| {
300            ExtensionError(format!("extension directory is unavailable: {error}"))
301        })?;
302        if !metadata.is_dir() {
303            return Err(ExtensionError("extension path must be a directory".into()));
304        }
305        let mut entries = std::fs::read_dir(directory)
306            .map_err(|error| ExtensionError(format!("cannot read extension directory: {error}")))?
307            .collect::<Result<Vec<_>, _>>()
308            .map_err(|error| ExtensionError(format!("cannot enumerate extensions: {error}")))?;
309        entries.sort_by_key(|entry| entry.file_name());
310        if entries.len() > MAX_EXTENSION_MANIFESTS {
311            return Err(ExtensionError(format!(
312                "extension directory exceeds {MAX_EXTENSION_MANIFESTS} manifests"
313            )));
314        }
315        let mut registry = Self::default();
316        for entry in entries {
317            let path = entry.path();
318            if path.extension().and_then(|value| value.to_str()) != Some("json") {
319                continue;
320            }
321            let metadata = std::fs::symlink_metadata(&path)
322                .map_err(|error| ExtensionError(format!("cannot inspect manifest: {error}")))?;
323            if !metadata.is_file() {
324                return Err(ExtensionError(format!(
325                    "extension manifest is not a regular file: {}",
326                    path.display()
327                )));
328            }
329            let manifest: ExtensionManifest = serde_json::from_slice(
330                &std::fs::read(&path)
331                    .map_err(|error| ExtensionError(format!("cannot read manifest: {error}")))?,
332            )
333            .map_err(|error| ExtensionError(format!("invalid extension manifest: {error}")))?;
334            registry.register(manifest)?;
335        }
336        Ok(registry)
337    }
338}
339
340impl ExtensionHost {
341    /// Create a host whose entrypoints must remain below `root`.
342    pub fn new(
343        root: impl AsRef<Path>,
344        registry: ExtensionRegistry,
345    ) -> Result<Self, ExtensionError> {
346        let root = std::fs::canonicalize(root.as_ref())
347            .map_err(|error| ExtensionError(format!("extension root is unavailable: {error}")))?;
348        if !root.is_dir() {
349            return Err(ExtensionError("extension root must be a directory".into()));
350        }
351        Ok(Self {
352            root,
353            registry,
354            invocations: Arc::new(Semaphore::new(MAX_EXTENSION_CONCURRENT_INVOCATIONS)),
355            experimental_enabled: false,
356        })
357    }
358
359    /// Opt into the experimental extension capability for this host.
360    ///
361    /// Invocations still require a detected native sandbox. This opt-in does
362    /// not authorize the unsandboxed subprocess helper.
363    pub fn with_experimental_extensions(mut self) -> Self {
364        self.experimental_enabled = true;
365        self
366    }
367
368    /// Report the sandbox that an explicit guarded invocation would use.
369    pub fn sandbox(&self) -> ExtensionSandbox {
370        ExtensionSandbox::detect()
371    }
372
373    /// Invoke one declared extension capability through a bounded subprocess.
374    #[cfg(test)]
375    pub(crate) async fn invoke(
376        &self,
377        extension_id: &str,
378        capability: ExtensionCapability,
379        host: &str,
380        action: &str,
381        payload: Value,
382    ) -> Result<Value, ExtensionError> {
383        self.invoke_internal(extension_id, capability, host, action, payload, None)
384            .await
385    }
386
387    /// Invoke one extension only inside the detected native sandbox.
388    pub async fn invoke_sandboxed(
389        &self,
390        extension_id: &str,
391        capability: ExtensionCapability,
392        host: &str,
393        action: &str,
394        payload: Value,
395    ) -> Result<Value, ExtensionError> {
396        if !self.experimental_enabled {
397            return Err(ExtensionError(
398                "experimental extensions are disabled; opt in explicitly".into(),
399            ));
400        }
401        if !experimental_extension_target_supported() {
402            return Err(ExtensionError(
403                "experimental extensions are unavailable on this target".into(),
404            ));
405        }
406        let sandbox = self.sandbox();
407        if sandbox == ExtensionSandbox::Unavailable {
408            return Err(ExtensionError(
409                "no supported native extension sandbox is available".into(),
410            ));
411        }
412        self.invoke_internal(
413            extension_id,
414            capability,
415            host,
416            action,
417            payload,
418            Some(sandbox),
419        )
420        .await
421    }
422
423    /// Invoke an extension and dispatch its returned action through the core
424    /// revision-guarded browser methods.
425    ///
426    /// The extension may suggest only `click`, `type`, `clear`, `check`,
427    /// `uncheck`, or `select`. The returned request must include the current
428    /// observation revision. Policy checks, target resolution, verification,
429    /// and effect recording remain owned by `BrowserSession`.
430    pub async fn invoke_guarded(
431        &self,
432        session: &crate::browser::BrowserSession,
433        extension_id: &str,
434        capability: ExtensionCapability,
435        host: &str,
436        action: &str,
437        payload: Value,
438    ) -> Result<crate::browser::ActionOutcome, ExtensionError> {
439        let result = self
440            .invoke_sandboxed(extension_id, capability, host, action, payload)
441            .await?;
442        let guarded: ExtensionGuardedAction = serde_json::from_value(result).map_err(|error| {
443            ExtensionError(format!("invalid guarded extension action: {error}"))
444        })?;
445        guarded.validate()?;
446        let manifest = self
447            .registry
448            .get(extension_id)
449            .ok_or_else(|| ExtensionError("extension is not registered".into()))?;
450        if !manifest
451            .permissions
452            .actions
453            .iter()
454            .any(|allowed| allowed == &guarded.action)
455        {
456            return Err(ExtensionError(
457                "extension returned an action outside its declared permissions".into(),
458            ));
459        }
460        let target = || {
461            guarded
462                .target
463                .as_deref()
464                .ok_or_else(|| ExtensionError("guarded extension action requires a target".into()))
465        };
466        let revision = guarded.expected_revision;
467        match guarded.action.as_str() {
468            "click" => session
469                .click_with_revision(target()?, revision)
470                .await
471                .map_err(extension_browser_error),
472            "type" => session
473                .type_text_with_expected_revision(
474                    guarded.value.as_deref().unwrap_or_default(),
475                    Some(target()?),
476                    Some(revision),
477                )
478                .await
479                .map_err(extension_browser_error),
480            "clear" => session
481                .clear_with_revision(target()?, Some(revision))
482                .await
483                .map_err(extension_browser_error),
484            "check" => session
485                .check_with_revision(target()?, Some(revision))
486                .await
487                .map_err(extension_browser_error),
488            "uncheck" => session
489                .uncheck_with_revision(target()?, Some(revision))
490                .await
491                .map_err(extension_browser_error),
492            "select" => session
493                .select_option_with_revision(
494                    target()?,
495                    guarded.value.as_deref().unwrap_or_default(),
496                    Some(revision),
497                )
498                .await
499                .map_err(extension_browser_error),
500            other => Err(ExtensionError(format!(
501                "guarded extension action is not supported: {other}"
502            ))),
503        }
504    }
505
506    async fn invoke_internal(
507        &self,
508        extension_id: &str,
509        capability: ExtensionCapability,
510        host: &str,
511        action: &str,
512        payload: Value,
513        sandbox: Option<ExtensionSandbox>,
514    ) -> Result<Value, ExtensionError> {
515        let _permit = tokio::time::timeout(
516            EXTENSION_TIMEOUT,
517            Arc::clone(&self.invocations).acquire_owned(),
518        )
519        .await
520        .map_err(|_| ExtensionError("extension invocation queue timed out".into()))?
521        .map_err(|_| ExtensionError("extension invocation queue is closed".into()))?;
522        let manifest = self
523            .registry
524            .get(extension_id)
525            .ok_or_else(|| ExtensionError("extension is not registered".into()))?;
526        if !manifest.capabilities.contains(&capability) {
527            return Err(ExtensionError(
528                "extension capability was not declared".into(),
529            ));
530        }
531        if !manifest
532            .permissions
533            .hosts
534            .iter()
535            .any(|allowed| allowed == host)
536        {
537            return Err(ExtensionError(
538                "extension host permission was not declared".into(),
539            ));
540        }
541        if !manifest
542            .permissions
543            .actions
544            .iter()
545            .any(|allowed| allowed == action)
546        {
547            return Err(ExtensionError(
548                "extension action permission was not declared".into(),
549            ));
550        }
551        let entrypoint = manifest
552            .entrypoint
553            .as_deref()
554            .ok_or_else(|| ExtensionError("extension has no executable entrypoint".into()))?;
555        let entrypoint = confined_entrypoint(&self.root, entrypoint)?;
556        let invocation = ExtensionInvocation {
557            protocol_version: EXTENSION_SCHEMA_VERSION,
558            extension_id: extension_id.into(),
559            capability,
560            host: host.into(),
561            action: action.into(),
562            payload,
563        };
564        let encoded = serde_json::to_vec(&invocation)
565            .map_err(|error| ExtensionError(format!("cannot encode extension request: {error}")))?;
566        if encoded.len() > MAX_EXTENSION_MESSAGE_BYTES {
567            return Err(ExtensionError(
568                "extension request exceeds the size limit".into(),
569            ));
570        }
571        let mut command = match sandbox {
572            None => Command::new(entrypoint),
573            Some(ExtensionSandbox::LinuxBubblewrap) => {
574                let mut command = Command::new("bwrap");
575                command.args([
576                    "--die-with-parent",
577                    "--new-session",
578                    "--unshare-all",
579                    "--proc",
580                    "/proc",
581                    "--dev",
582                    "/dev",
583                    "--tmpfs",
584                    "/tmp",
585                    "--ro-bind",
586                ]);
587                command.arg(&self.root).arg(&self.root);
588                for path in ["/bin", "/usr", "/lib", "/lib64"] {
589                    if Path::new(path).is_dir() {
590                        command.arg("--ro-bind").arg(path).arg(path);
591                    }
592                }
593                command.arg("--chdir").arg(&self.root).arg(entrypoint);
594                command
595            }
596            Some(ExtensionSandbox::MacSandboxExec) => {
597                let root = self
598                    .root
599                    .to_str()
600                    .ok_or_else(|| ExtensionError("extension root is not valid UTF-8".into()))?
601                    .replace('\\', "\\\\")
602                    .replace('"', "\\\"");
603                let profile = format!(
604                    "(version 1)(deny default)(allow process-exec)(allow file-read* (subpath \"{root}\"))(allow file-read* (subpath \"/bin\"))(allow file-read* (subpath \"/usr/bin\"))(allow file-read* (subpath \"/usr/lib\"))(allow file-write* (subpath \"/tmp\"))"
605                );
606                let mut command = Command::new("/usr/bin/sandbox-exec");
607                command.arg("-p").arg(profile).arg(entrypoint);
608                command
609            }
610            Some(ExtensionSandbox::Unavailable) => {
611                return Err(ExtensionError(
612                    "no supported native extension sandbox is available".into(),
613                ));
614            }
615        };
616        let mut child = command
617            .current_dir(&self.root)
618            .stdin(std::process::Stdio::piped())
619            .stdout(std::process::Stdio::piped())
620            .stderr(std::process::Stdio::null())
621            .spawn()
622            .map_err(|error| {
623                ExtensionError(format!("extension process could not start: {error}"))
624            })?;
625        let mut stdin = child
626            .stdin
627            .take()
628            .ok_or_else(|| ExtensionError("extension stdin is unavailable".into()))?;
629        let mut stdout = BufReader::new(
630            child
631                .stdout
632                .take()
633                .ok_or_else(|| ExtensionError("extension stdout is unavailable".into()))?,
634        );
635        stdin
636            .write_all(&encoded)
637            .await
638            .map_err(|error| ExtensionError(format!("extension request failed: {error}")))?;
639        stdin
640            .write_all(b"\n")
641            .await
642            .map_err(|error| ExtensionError(format!("extension request failed: {error}")))?;
643        stdin
644            .shutdown()
645            .await
646            .map_err(|error| ExtensionError(format!("extension request failed: {error}")))?;
647        let result = tokio::time::timeout(EXTENSION_TIMEOUT, async {
648            let mut response = Vec::new();
649            let bytes = stdout
650                .read_until(b'\n', &mut response)
651                .await
652                .map_err(|error| ExtensionError(format!("extension response failed: {error}")))?;
653            if bytes == 0 || response.len() > MAX_EXTENSION_MESSAGE_BYTES {
654                return Err(ExtensionError(
655                    "extension response is missing or oversized".into(),
656                ));
657            }
658            let response: ExtensionResponse = serde_json::from_slice(&response)
659                .map_err(|error| ExtensionError(format!("invalid extension response: {error}")))?;
660            if response.protocol_version != EXTENSION_SCHEMA_VERSION {
661                return Err(ExtensionError(
662                    "unsupported extension response protocol".into(),
663                ));
664            }
665            match (response.ok, response.result, response.error) {
666                (true, Some(result), None) => {
667                    validate_extension_value(&result)?;
668                    Ok(result)
669                }
670                (false, None, Some(error)) if error.len() <= 512 => Err(ExtensionError(error)),
671                _ => Err(ExtensionError(
672                    "extension response outcome is invalid".into(),
673                )),
674            }
675        })
676        .await;
677        let timed_out = result.is_err();
678        if timed_out {
679            let _ = child.kill().await;
680        }
681        let status = child
682            .wait()
683            .await
684            .map_err(|error| ExtensionError(format!("extension process failed: {error}")))?;
685        if !status.success() && !timed_out {
686            return Err(ExtensionError(
687                "extension process exited unsuccessfully".into(),
688            ));
689        }
690        if timed_out {
691            return Err(ExtensionError("extension invocation timed out".into()));
692        }
693        result.expect("extension timeout was handled")
694    }
695}
696
697fn extension_browser_error(error: impl fmt::Display) -> ExtensionError {
698    ExtensionError(format!("guarded extension action failed: {error}"))
699}
700
701fn validate_extension_value(value: &Value) -> Result<(), ExtensionError> {
702    fn visit(value: &Value, depth: usize, nodes: &mut usize) -> Result<(), ExtensionError> {
703        *nodes = nodes.saturating_add(1);
704        if *nodes > MAX_EXTENSION_VALUE_NODES {
705            return Err(ExtensionError(
706                "extension response contains too many values".into(),
707            ));
708        }
709        if depth > MAX_EXTENSION_VALUE_DEPTH {
710            return Err(ExtensionError(
711                "extension response nesting exceeds the limit".into(),
712            ));
713        }
714        match value {
715            Value::String(value) if value.len() > MAX_EXTENSION_STRING_BYTES => Err(
716                ExtensionError("extension response string is oversized".into()),
717            ),
718            Value::Array(values) => {
719                if values.len() > MAX_EXTENSION_VALUE_NODES {
720                    return Err(ExtensionError(
721                        "extension response array is oversized".into(),
722                    ));
723                }
724                for value in values {
725                    visit(value, depth + 1, nodes)?;
726                }
727                Ok(())
728            }
729            Value::Object(values) => {
730                for (key, value) in values {
731                    let normalized = key.to_ascii_lowercase();
732                    if ["authorization", "cookie", "password", "secret", "token"]
733                        .iter()
734                        .any(|blocked| normalized.contains(blocked))
735                    {
736                        return Err(ExtensionError(format!(
737                            "extension response contains a sensitive field: {key}"
738                        )));
739                    }
740                    visit(value, depth + 1, nodes)?;
741                }
742                Ok(())
743            }
744            _ => Ok(()),
745        }
746    }
747
748    let mut nodes = 0;
749    visit(value, 0, &mut nodes)
750}
751
752fn confined_entrypoint(root: &Path, entrypoint: &str) -> Result<PathBuf, ExtensionError> {
753    let path = Path::new(entrypoint);
754    if path.is_absolute()
755        || path
756            .components()
757            .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
758    {
759        return Err(ExtensionError(
760            "extension entrypoint escapes its root".into(),
761        ));
762    }
763    let path = std::fs::canonicalize(root.join(path))
764        .map_err(|error| ExtensionError(format!("extension entrypoint is unavailable: {error}")))?;
765    if !path.starts_with(root) || !path.is_file() {
766        return Err(ExtensionError(
767            "extension entrypoint is not a file below its root".into(),
768        ));
769    }
770    Ok(path)
771}
772
773fn allowed_action(action: &str) -> bool {
774    matches!(
775        action,
776        "navigate"
777            | "observe"
778            | "verify"
779            | "click"
780            | "type"
781            | "clear"
782            | "check"
783            | "uncheck"
784            | "select"
785            | "scroll"
786            | "extract"
787    )
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    fn manifest() -> ExtensionManifest {
795        ExtensionManifest {
796            schema_version: EXTENSION_SCHEMA_VERSION,
797            id: "example.docs-adapter".into(),
798            version: "1.0.0".into(),
799            api_version: 1,
800            capabilities: vec![ExtensionCapability::SiteAdapter],
801            permissions: ExtensionPermissions {
802                hosts: vec!["docs.example.com".into()],
803                actions: vec!["navigate".into(), "observe".into(), "extract".into()],
804            },
805            entrypoint: Some("metadata-only".into()),
806        }
807    }
808
809    #[test]
810    fn manifest_validation_is_strict_and_does_not_grant_execution() {
811        let mut registry = ExtensionRegistry::default();
812        registry.register(manifest()).unwrap();
813        assert_eq!(registry.manifests().count(), 1);
814        assert!(registry.register(manifest()).is_err());
815
816        let mut wildcard = manifest();
817        wildcard.permissions.hosts = vec!["*".into()];
818        assert!(wildcard.validate().is_err());
819    }
820
821    #[test]
822    fn mutation_permissions_are_explicitly_bounded() {
823        let mut extension = manifest();
824        extension.permissions.actions = vec!["evaluate".into()];
825        assert!(extension.validate().is_err());
826    }
827
828    #[cfg(unix)]
829    #[tokio::test]
830    async fn host_enforces_permissions_and_runs_one_bounded_rpc() {
831        use std::os::unix::fs::PermissionsExt;
832
833        let root =
834            std::env::temp_dir().join(format!("glass-extension-host-{}", std::process::id()));
835        std::fs::create_dir_all(&root).unwrap();
836        let entrypoint = root.join("extension.sh");
837        std::fs::write(
838            &entrypoint,
839            "#!/bin/sh\nread request\nprintf '%s\\n' '{\"protocolVersion\":1,\"ok\":true,\"result\":{\"accepted\":true}}'\n",
840        )
841        .unwrap();
842        std::fs::set_permissions(&entrypoint, std::fs::Permissions::from_mode(0o700)).unwrap();
843
844        let mut extension = manifest();
845        extension.entrypoint = Some("extension.sh".into());
846        let mut registry = ExtensionRegistry::default();
847        registry.register(extension).unwrap();
848        let host = ExtensionHost::new(&root, registry).unwrap();
849        let result = host
850            .invoke(
851                "example.docs-adapter",
852                ExtensionCapability::SiteAdapter,
853                "docs.example.com",
854                "observe",
855                serde_json::json!({"revision": 1}),
856            )
857            .await
858            .unwrap();
859        assert_eq!(result["accepted"], true);
860        assert!(
861            host.invoke(
862                "example.docs-adapter",
863                ExtensionCapability::SiteAdapter,
864                "other.example.com",
865                "observe",
866                serde_json::json!({}),
867            )
868            .await
869            .is_err()
870        );
871
872        std::fs::remove_file(entrypoint).unwrap();
873        std::fs::remove_dir(root).unwrap();
874    }
875
876    #[cfg(unix)]
877    #[tokio::test]
878    async fn first_party_reference_extensions_load_and_run_through_the_host() {
879        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
880        let registry = ExtensionRegistry::load_dir(&root).unwrap();
881        assert_eq!(registry.manifests().count(), 2);
882        let host = ExtensionHost::new(&root, registry).unwrap();
883
884        let title = host
885            .invoke(
886                "glass.first-party.title-extractor",
887                ExtensionCapability::ExtractionTransform,
888                "example.com",
889                "extract",
890                serde_json::json!({"title": "Example Domain"}),
891            )
892            .await
893            .unwrap();
894        assert_eq!(title["extension"], "title-extractor");
895
896        let evidence = host
897            .invoke(
898                "glass.first-party.intent-evidence",
899                ExtensionCapability::IntentEvidencePack,
900                "example.com",
901                "verify",
902                serde_json::json!({"role": "button"}),
903            )
904            .await
905            .unwrap();
906        assert_eq!(evidence["extension"], "intent-evidence");
907    }
908
909    #[cfg(unix)]
910    #[tokio::test]
911    async fn first_party_extensions_pass_cold_start_exit_and_restart_lifecycle() {
912        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
913        let registry = ExtensionRegistry::load_dir(&root).unwrap();
914        let host = ExtensionHost::new(&root, registry).unwrap();
915
916        for manifest in host.registry.manifests() {
917            let capability = manifest.capabilities[0];
918            let host_name = &manifest.permissions.hosts[0];
919            let action = &manifest.permissions.actions[0];
920            let first = host
921                .invoke(
922                    &manifest.id,
923                    capability,
924                    host_name,
925                    action,
926                    serde_json::json!({"lifecycle": "cold-start"}),
927                )
928                .await
929                .unwrap();
930            let second = host
931                .invoke(
932                    &manifest.id,
933                    capability,
934                    host_name,
935                    action,
936                    serde_json::json!({"lifecycle": "restart"}),
937                )
938                .await
939                .unwrap();
940            assert_eq!(first["extension"], second["extension"]);
941        }
942    }
943
944    #[cfg(unix)]
945    #[tokio::test]
946    async fn sandboxed_reference_extensions_pass_native_gate() {
947        if std::env::var("GLASS_EXTENSION_SANDBOX_E2E").as_deref() != Ok("1") {
948            eprintln!("skipping native extension sandbox gate; set GLASS_EXTENSION_SANDBOX_E2E=1");
949            return;
950        }
951        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
952        let host = ExtensionHost::new(&root, ExtensionRegistry::load_dir(&root).unwrap())
953            .unwrap()
954            .with_experimental_extensions();
955        assert_ne!(host.sandbox(), ExtensionSandbox::Unavailable);
956        let result = host
957            .invoke_sandboxed(
958                "glass.first-party.title-extractor",
959                ExtensionCapability::ExtractionTransform,
960                "example.com",
961                "extract",
962                serde_json::json!({"title": "Example Domain"}),
963            )
964            .await
965            .unwrap();
966        assert_eq!(result["extension"], "title-extractor");
967    }
968
969    #[tokio::test]
970    async fn sandboxed_extensions_require_explicit_experimental_opt_in() {
971        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
972        let host = ExtensionHost::new(&root, ExtensionRegistry::load_dir(&root).unwrap()).unwrap();
973        let error = host
974            .invoke_sandboxed(
975                "glass.first-party.title-extractor",
976                ExtensionCapability::ExtractionTransform,
977                "example.com",
978                "extract",
979                serde_json::json!({"title": "Example Domain"}),
980            )
981            .await
982            .unwrap_err();
983        assert!(error.0.contains("experimental extensions are disabled"));
984    }
985
986    #[test]
987    fn sandbox_detection_is_explicit_and_never_falls_back_silently() {
988        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
989        let host = ExtensionHost::new(&root, ExtensionRegistry::load_dir(&root).unwrap()).unwrap();
990        assert!(!host.sandbox().label().is_empty());
991        assert_eq!(
992            host.invocations.available_permits(),
993            MAX_EXTENSION_CONCURRENT_INVOCATIONS
994        );
995    }
996
997    #[tokio::test]
998    async fn unavailable_sandbox_never_falls_back_to_an_unsandboxed_process() {
999        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("extensions/first-party");
1000        let host = ExtensionHost::new(&root, ExtensionRegistry::load_dir(&root).unwrap()).unwrap();
1001        let error = host
1002            .invoke_internal(
1003                "glass.first-party.title-extractor",
1004                ExtensionCapability::ExtractionTransform,
1005                "example.com",
1006                "extract",
1007                serde_json::json!({"title": "Example Domain"}),
1008                Some(ExtensionSandbox::Unavailable),
1009            )
1010            .await
1011            .unwrap_err();
1012        assert!(error.0.contains("no supported native extension sandbox"));
1013    }
1014
1015    #[test]
1016    fn guarded_extension_actions_require_revision_and_bounded_fields() {
1017        let action: ExtensionGuardedAction = serde_json::from_value(serde_json::json!({
1018            "action": "click",
1019            "target": "ref=r7:b42",
1020            "expectedRevision": 7
1021        }))
1022        .unwrap();
1023        action.validate().unwrap();
1024
1025        let mut missing_revision = action.clone();
1026        missing_revision.expected_revision = 0;
1027        assert!(missing_revision.validate().is_err());
1028
1029        let mut oversized = action;
1030        oversized.target = Some("x".repeat(513));
1031        assert!(oversized.validate().is_err());
1032    }
1033
1034    #[test]
1035    fn guarded_extension_actions_reject_unknown_fields() {
1036        assert!(
1037            serde_json::from_value::<ExtensionGuardedAction>(serde_json::json!({
1038                "action": "click",
1039                "target": "ref=r7:b42",
1040                "expectedRevision": 7,
1041                "dispatchDirectly": true
1042            }))
1043            .is_err()
1044        );
1045    }
1046
1047    #[test]
1048    fn extension_outputs_fail_closed_on_sensitive_fields_and_unbounded_shapes() {
1049        assert!(
1050            validate_extension_value(&serde_json::json!({
1051                "evidence": {"authorization": "redacted"}
1052            }))
1053            .is_err()
1054        );
1055        assert!(
1056            validate_extension_value(&serde_json::json!({
1057                "text": "x".repeat(MAX_EXTENSION_STRING_BYTES + 1)
1058            }))
1059            .is_err()
1060        );
1061    }
1062
1063    #[test]
1064    fn host_rejects_entrypoints_that_escape_root() {
1065        let root = std::env::current_dir().unwrap();
1066        let mut registry = ExtensionRegistry::default();
1067        let mut extension = manifest();
1068        extension.entrypoint = Some("../outside".into());
1069        registry.register(extension).unwrap();
1070        let host = ExtensionHost::new(&root, registry).unwrap();
1071        let runtime = tokio::runtime::Runtime::new().unwrap();
1072        let error = runtime.block_on(host.invoke(
1073            "example.docs-adapter",
1074            ExtensionCapability::SiteAdapter,
1075            "docs.example.com",
1076            "observe",
1077            serde_json::json!({}),
1078        ));
1079        assert!(error.unwrap_err().0.contains("escapes"));
1080    }
1081}