use std::sync::Arc;
use super::store::{TrustKey, TrustStore};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
LocalFile { path: std::path::PathBuf },
LexTomlNamespace { name: String },
CacheOnly { uri: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Surface {
CliOneShot,
LspSession,
Ci,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
Pure,
Full,
}
impl Capability {
pub fn from_schema(caps: lex_extension::schema::Capabilities) -> Self {
if caps.is_pure() {
Capability::Pure
} else {
Capability::Full
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Native,
Subprocess,
Wasm,
}
impl Transport {
pub fn from_schema(t: lex_extension::schema::HandlerTransport) -> Self {
match t {
lex_extension::schema::HandlerTransport::Native => Transport::Native,
lex_extension::schema::HandlerTransport::Subprocess => Transport::Subprocess,
lex_extension::schema::HandlerTransport::Wasm => Transport::Wasm,
_ => Transport::Subprocess,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustDecision {
Trusted,
Denied { reason: String },
Pending,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrustPromptContext {
pub namespace: String,
pub command_string: String,
pub source: Source,
pub capability: Capability,
}
pub trait TrustPromptHandler: Send + Sync {
fn prompt(&self, ctx: &TrustPromptContext) -> TrustDecision;
}
pub struct TrustGate {
surface: Surface,
enable_handlers: bool,
store: TrustStore,
prompt: Box<dyn TrustPromptHandler>,
sandbox: Arc<dyn crate::sandbox::Sandbox>,
}
impl TrustGate {
pub fn new(
surface: Surface,
enable_handlers: bool,
store: TrustStore,
prompt: Box<dyn TrustPromptHandler>,
) -> Self {
Self {
surface,
enable_handlers,
store,
prompt,
sandbox: Arc::new(crate::sandbox::NullSandbox),
}
}
pub fn set_sandbox(&mut self, sandbox: Arc<dyn crate::sandbox::Sandbox>) {
self.sandbox = sandbox;
}
pub fn sandbox(&self) -> Arc<dyn crate::sandbox::Sandbox> {
Arc::clone(&self.sandbox)
}
pub fn surface(&self) -> Surface {
self.surface
}
pub fn enable_handlers(&self) -> bool {
self.enable_handlers
}
pub fn evaluate(
&mut self,
source: &Source,
transport: Transport,
capability: Capability,
namespace: &str,
command_string: &str,
) -> TrustDecision {
if matches!(transport, Transport::Native) {
return TrustDecision::Trusted;
}
if matches!(transport, Transport::Wasm) {
return TrustDecision::Denied {
reason: "WASM handlers are not yet supported".into(),
};
}
if matches!(capability, Capability::Pure)
&& self
.sandbox
.supports(lex_extension::schema::Capabilities::default())
{
return TrustDecision::Trusted;
}
match self.surface {
Surface::CliOneShot | Surface::Ci => {
if self.enable_handlers {
TrustDecision::Trusted
} else {
TrustDecision::Denied {
reason: format!(
"subprocess handler `{namespace}` requires --enable-handlers in {} mode",
match self.surface {
Surface::Ci => "CI",
_ => "CLI",
}
),
}
}
}
Surface::LspSession => {
let key = TrustKey {
namespace: namespace.to_string(),
command_string: command_string.to_string(),
};
if let Some(stored) = self.store.get(&key) {
return stored.clone();
}
let ctx = TrustPromptContext {
namespace: namespace.to_string(),
command_string: command_string.to_string(),
source: source.clone(),
capability,
};
let decision = self.prompt.prompt(&ctx);
let to_store = match &decision {
TrustDecision::Trusted => Some(decision.clone()),
TrustDecision::Denied { .. } => Some(decision.clone()),
TrustDecision::Pending => None,
};
if let Some(decision_to_store) = to_store {
if let Err(e) = self.store.set(key, decision_to_store) {
eprintln!(
"[lex-extension-host] trust store persist failed for `{namespace}`: {e}; approval applies for this session only"
);
}
}
match decision {
TrustDecision::Pending => TrustDecision::Denied {
reason: format!(
"trust prompt for `{namespace}` returned Pending — treating as denied"
),
},
other => other,
}
}
}
}
pub fn store(&self) -> &TrustStore {
&self.store
}
}
pub fn detect_ci_environment<F>(env_lookup: F) -> bool
where
F: Fn(&str) -> Option<String>,
{
const CI_VARS: &[&str] = &[
"CI",
"CONTINUOUS_INTEGRATION",
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"CIRCLECI",
"TRAVIS",
"JENKINS_URL",
];
CI_VARS.iter().any(|v| env_lookup(v).is_some())
}
#[cfg(test)]
mod tests {
use super::*;
struct FixedPrompt(TrustDecision);
impl TrustPromptHandler for FixedPrompt {
fn prompt(&self, _ctx: &TrustPromptContext) -> TrustDecision {
self.0.clone()
}
}
fn store_in_tmp() -> (TrustStore, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let store = TrustStore::open(dir.path()).expect("open");
(store, dir)
}
fn gate_with_surface(
surface: Surface,
enable_handlers: bool,
prompt_decision: TrustDecision,
) -> (TrustGate, tempfile::TempDir) {
let (store, dir) = store_in_tmp();
let gate = TrustGate::new(
surface,
enable_handlers,
store,
Box::new(FixedPrompt(prompt_decision)),
);
(gate, dir)
}
#[test]
fn native_is_trusted_under_every_surface() {
for surface in [Surface::CliOneShot, Surface::LspSession, Surface::Ci] {
let (mut gate, _dir) = gate_with_surface(
surface,
false,
TrustDecision::Denied {
reason: "should not be called".into(),
},
);
let d = gate.evaluate(
&Source::LexTomlNamespace { name: "lex".into() },
Transport::Native,
Capability::Full,
"lex",
"/usr/bin/never-spawned",
);
assert_eq!(d, TrustDecision::Trusted, "surface={surface:?}");
}
}
#[test]
fn cli_subprocess_without_flag_is_denied() {
let (mut gate, _dir) = gate_with_surface(
Surface::CliOneShot,
false,
TrustDecision::Denied {
reason: "n/a".into(),
},
);
let d = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Pure,
"acme",
"acme-handler",
);
match d {
TrustDecision::Denied { reason } => {
assert!(reason.contains("--enable-handlers"));
assert!(reason.contains("acme"));
}
other => panic!("expected Denied, got: {other:?}"),
}
}
#[test]
fn cli_subprocess_with_flag_is_trusted() {
let (mut gate, _dir) = gate_with_surface(
Surface::CliOneShot,
true,
TrustDecision::Denied {
reason: "n/a".into(),
},
);
let d = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Pure,
"acme",
"acme-handler",
);
assert_eq!(d, TrustDecision::Trusted);
}
#[test]
fn cli_with_flag_does_not_persist_to_store() {
let (mut gate, _dir) = gate_with_surface(
Surface::CliOneShot,
true,
TrustDecision::Denied {
reason: "n/a".into(),
},
);
gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Pure,
"acme",
"acme-handler",
);
let key = TrustKey {
namespace: "acme".into(),
command_string: "acme-handler".into(),
};
assert!(
gate.store().get(&key).is_none(),
"CLI --enable-handlers must not persist trust"
);
}
#[test]
fn ci_subprocess_without_flag_is_denied() {
let (mut gate, _dir) = gate_with_surface(
Surface::Ci,
false,
TrustDecision::Denied {
reason: "n/a".into(),
},
);
let d = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Pure,
"acme",
"acme-handler",
);
match d {
TrustDecision::Denied { reason } => assert!(reason.contains("CI")),
other => panic!("expected Denied, got: {other:?}"),
}
}
#[test]
fn ci_subprocess_with_flag_is_trusted() {
let (mut gate, _dir) = gate_with_surface(
Surface::Ci,
true,
TrustDecision::Denied {
reason: "n/a".into(),
},
);
let d = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Pure,
"acme",
"acme-handler",
);
assert_eq!(d, TrustDecision::Trusted);
}
#[test]
fn lsp_first_call_invokes_prompt_and_persists_trusted() {
let (mut gate, _dir) =
gate_with_surface(Surface::LspSession, false, TrustDecision::Trusted);
let d = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Pure,
"acme",
"acme-handler",
);
assert_eq!(d, TrustDecision::Trusted);
let key = TrustKey {
namespace: "acme".into(),
command_string: "acme-handler".into(),
};
assert_eq!(gate.store().get(&key), Some(&TrustDecision::Trusted));
}
#[test]
fn lsp_subsequent_call_uses_pinned_decision_without_prompt() {
let (store, _dir) = store_in_tmp();
let mut store = store;
let key = TrustKey {
namespace: "acme".into(),
command_string: "acme-handler".into(),
};
store.set(key.clone(), TrustDecision::Trusted).unwrap();
let mut gate = TrustGate::new(
Surface::LspSession,
false,
store,
Box::new(FixedPrompt(TrustDecision::Denied {
reason: "MUST NOT FIRE".into(),
})),
);
let d = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Pure,
"acme",
"acme-handler",
);
assert_eq!(d, TrustDecision::Trusted);
}
#[test]
fn lsp_command_string_change_re_prompts() {
let (store, _dir) = store_in_tmp();
let mut store = store;
store
.set(
TrustKey {
namespace: "acme".into(),
command_string: "acme-handler-v1".into(),
},
TrustDecision::Trusted,
)
.unwrap();
let mut gate = TrustGate::new(
Surface::LspSession,
false,
store,
Box::new(FixedPrompt(TrustDecision::Denied {
reason: "v2 command needs fresh approval".into(),
})),
);
let d = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Pure,
"acme",
"acme-handler-v2",
);
match d {
TrustDecision::Denied { reason } => {
assert!(reason.contains("v2"));
}
other => panic!("expected fresh prompt to deny, got: {other:?}"),
}
}
#[test]
fn lsp_denied_decision_persists() {
let (mut gate, _dir) = gate_with_surface(
Surface::LspSession,
false,
TrustDecision::Denied {
reason: "user rejected".into(),
},
);
let _ = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Pure,
"acme",
"acme-handler",
);
let key = TrustKey {
namespace: "acme".into(),
command_string: "acme-handler".into(),
};
assert!(matches!(
gate.store().get(&key),
Some(TrustDecision::Denied { .. })
));
}
#[test]
fn wasm_transport_is_denied_defensively() {
let (mut gate, _dir) = gate_with_surface(Surface::CliOneShot, true, TrustDecision::Trusted);
let d = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Wasm,
Capability::Pure,
"acme",
"acme.wasm",
);
assert!(matches!(d, TrustDecision::Denied { .. }));
}
#[test]
fn ci_detection_recognises_standard_env_vars() {
for var in ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI"] {
let lookup = |name: &str| -> Option<String> {
if name == var {
Some("1".into())
} else {
None
}
};
assert!(detect_ci_environment(lookup), "var={var}");
}
}
#[test]
fn ci_detection_returns_false_when_no_var_set() {
assert!(!detect_ci_environment(|_| None));
}
struct FixedSupportSandbox(bool);
impl crate::sandbox::Sandbox for FixedSupportSandbox {
fn apply_to(
&self,
_cmd: &mut std::process::Command,
_caps: lex_extension::schema::Capabilities,
) -> Result<(), crate::sandbox::SandboxError> {
Ok(())
}
fn supports(&self, _caps: lex_extension::schema::Capabilities) -> bool {
self.0
}
}
#[test]
fn default_gate_installs_null_sandbox_which_supports_nothing() {
let (gate, _dir) = gate_with_surface(
Surface::LspSession,
false,
TrustDecision::Denied {
reason: "n/a".into(),
},
);
assert!(!gate
.sandbox()
.supports(lex_extension::schema::Capabilities::default()));
}
#[test]
fn pure_handler_auto_trusts_when_sandbox_supports_pure() {
for surface in [Surface::CliOneShot, Surface::LspSession, Surface::Ci] {
let (mut gate, _dir) = gate_with_surface(
surface,
false,
TrustDecision::Denied {
reason: "prompt should not fire".into(),
},
);
gate.set_sandbox(Arc::new(FixedSupportSandbox(true)));
let d = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Pure,
"acme",
"acme-handler",
);
assert_eq!(d, TrustDecision::Trusted, "surface={surface:?}");
}
}
#[test]
fn full_capability_handler_does_not_auto_trust_even_under_sandbox() {
let (mut gate, _dir) = gate_with_surface(
Surface::CliOneShot,
false,
TrustDecision::Denied {
reason: "n/a".into(),
},
);
gate.set_sandbox(Arc::new(FixedSupportSandbox(true)));
let d = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Full,
"acme",
"acme-handler",
);
match d {
TrustDecision::Denied { reason } => {
assert!(reason.contains("--enable-handlers"));
}
other => panic!("expected Denied (full caps still prompts), got: {other:?}"),
}
}
#[test]
fn pure_handler_falls_back_to_prompt_when_sandbox_unsupported() {
let (mut gate, _dir) = gate_with_surface(
Surface::CliOneShot,
false,
TrustDecision::Denied {
reason: "n/a".into(),
},
);
gate.set_sandbox(Arc::new(FixedSupportSandbox(false)));
let d = gate.evaluate(
&Source::LexTomlNamespace {
name: "acme".into(),
},
Transport::Subprocess,
Capability::Pure,
"acme",
"acme-handler",
);
match d {
TrustDecision::Denied { reason } => {
assert!(reason.contains("--enable-handlers"));
}
other => panic!("expected Denied without enforced sandbox, got: {other:?}"),
}
}
#[test]
fn sandbox_arc_is_shared_with_callers() {
let (mut gate, _dir) = gate_with_surface(
Surface::LspSession,
false,
TrustDecision::Denied {
reason: "n/a".into(),
},
);
let original: Arc<dyn crate::sandbox::Sandbox> = Arc::new(FixedSupportSandbox(true));
gate.set_sandbox(Arc::clone(&original));
let from_gate = gate.sandbox();
assert!(Arc::ptr_eq(&original, &from_gate));
}
}