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