use crate::engine::wasm::host::util;
use crate::engine::wasm::host_state::HostState;
use astrid_approval::action::SensitiveAction;
use astrid_approval::{Allowance, AllowanceId, AllowancePattern, AllowanceStore};
use astrid_core::types::Timestamp;
use astrid_crypto::KeyPair;
use astrid_events::AstridEvent;
use astrid_events::ipc::{IpcMessage, IpcPayload};
use extism::{CurrentPlugin, Error, UserData, Val};
use serde::Deserialize;
use uuid::Uuid;
const MAX_APPROVAL_TIMEOUT_MS: u64 = 60_000;
const MAX_ACTION_LEN: usize = 256;
const MAX_RESOURCE_LEN: usize = 1024;
const MAX_RISK_LEVEL_LEN: usize = 64;
#[derive(Deserialize)]
struct GuestApprovalRequest {
action: String,
resource: String,
risk_level: String,
}
fn check_allowance(
store: &AllowanceStore,
resource: &str,
workspace_root: Option<&std::path::Path>,
) -> bool {
let action = SensitiveAction::ExecuteCommand {
command: resource.to_owned(),
args: vec![],
};
store
.find_matching_and_consume(&action, workspace_root)
.is_some()
}
fn sanitize_guest_field(s: &mut String, max_len: usize, field_name: &str, capsule_id: &str) {
let trimmed = s.trim();
let sanitized: String = trimmed
.chars()
.filter(|c| !c.is_control())
.take(max_len)
.collect();
if sanitized.len() != trimmed.len() {
let original_chars = trimmed.chars().count();
let sanitized_chars = sanitized.chars().count();
tracing::warn!(
capsule = %capsule_id,
field = field_name,
original_chars,
sanitized_chars,
"{field_name} sanitized: control characters stripped or length truncated"
);
}
*s = sanitized;
}
fn sanitize_action_for_pattern(action: &str, capsule_id: &str) -> String {
let trimmed = action.trim();
let sanitized: String = trimmed
.chars()
.filter(|c| !c.is_control())
.take(MAX_ACTION_LEN)
.collect();
let trimmed_chars = trimmed.chars().count();
let sanitized_chars = sanitized.chars().count();
if sanitized_chars != trimmed_chars {
tracing::warn!(
capsule = %capsule_id,
original_chars = trimmed_chars,
sanitized_chars = sanitized_chars,
"Action string sanitized: control characters stripped or length truncated"
);
}
sanitized
}
fn escape_glob_metacharacters(action: &str) -> String {
let mut escaped = String::with_capacity(action.len() * 2);
for c in action.chars() {
if matches!(c, '*' | '?' | '[' | ']' | '{' | '}' | '\\') {
escaped.push('\\');
}
escaped.push(c);
}
escaped
}
fn create_allowance_from_decision(
store: &AllowanceStore,
action: &str,
decision: &str,
workspace_root: Option<std::path::PathBuf>,
capsule_id: &str,
) {
let session_only = match decision {
"approve_session" => true,
"approve_always" => false,
_ => return,
};
let sanitized_action = sanitize_action_for_pattern(action, capsule_id);
if sanitized_action.is_empty() {
return;
}
let escaped_action = escape_glob_metacharacters(&sanitized_action);
let pattern = AllowancePattern::CommandPattern {
command: format!("{escaped_action} *"),
};
let keypair = KeyPair::generate();
let allowance = Allowance {
id: AllowanceId::new(),
action_pattern: pattern,
created_at: Timestamp::now(),
expires_at: None,
max_uses: None,
uses_remaining: None,
session_only,
workspace_root,
signature: keypair.sign(b"capsule-approval"),
};
if let Err(e) = store.add_allowance(allowance) {
tracing::warn!("Failed to add approval allowance: {e}");
}
}
#[expect(clippy::needless_pass_by_value)]
pub(crate) fn astrid_request_approval_impl(
plugin: &mut CurrentPlugin,
inputs: &[Val],
outputs: &mut [Val],
user_data: UserData<HostState>,
) -> Result<(), Error> {
let request_bytes = util::get_safe_bytes(plugin, &inputs[0], util::MAX_GUEST_PAYLOAD_LEN)?;
let mut guest_req: GuestApprovalRequest = serde_json::from_slice(&request_bytes)
.map_err(|e| Error::msg(format!("invalid approval request JSON: {e}")))?;
let ud = user_data.get()?;
let (
allowance_store,
event_bus,
runtime_handle,
capsule_id,
cancel_token,
host_semaphore,
workspace_root,
) = {
let state = ud
.lock()
.map_err(|e| Error::msg(format!("host state lock poisoned: {e}")))?;
let store = state.allowance_store.clone();
let event_bus = state.event_bus.clone();
let runtime_handle = state.runtime_handle.clone();
let capsule_id = state.capsule_id.to_string();
let cancel_token = state.cancel_token.clone();
let host_semaphore = state.host_semaphore.clone();
let workspace = state.workspace_root.clone();
(
store,
event_bus,
runtime_handle,
capsule_id,
cancel_token,
host_semaphore,
workspace,
)
};
let action_char_count = guest_req.action.chars().count();
if action_char_count > MAX_ACTION_LEN {
return Err(Error::msg(format!(
"approval request action exceeds maximum length ({action_char_count} > {MAX_ACTION_LEN})",
)));
}
guest_req.action = sanitize_action_for_pattern(&guest_req.action, &capsule_id);
sanitize_guest_field(
&mut guest_req.resource,
MAX_RESOURCE_LEN,
"resource",
&capsule_id,
);
sanitize_guest_field(
&mut guest_req.risk_level,
MAX_RISK_LEVEL_LEN,
"risk_level",
&capsule_id,
);
let ws_path = Some(workspace_root.as_path());
if let Some(ref store) = allowance_store
&& check_allowance(store, &guest_req.resource, ws_path)
{
let response = serde_json::to_vec(&serde_json::json!({
"approved": true,
"decision": "allowance",
}))
.map_err(|e| Error::msg(format!("failed to serialize response: {e}")))?;
tracing::debug!(
capsule = %capsule_id,
action = %guest_req.action,
resource = %guest_req.resource,
"Approval auto-granted via existing allowance"
);
let mem = plugin.memory_new(&response)?;
outputs[0] = plugin.memory_to_val(mem);
return Ok(());
}
let request_id = Uuid::new_v4().to_string();
let response_topic = format!("astrid.v1.approval.response.{request_id}");
let mut receiver = event_bus.subscribe_topic(&response_topic);
let request_payload = IpcPayload::ApprovalRequired {
request_id: request_id.clone(),
action: guest_req.action.clone(),
resource: guest_req.resource.clone(),
reason: format!("Capsule '{capsule_id}' requests approval"),
risk_level: guest_req.risk_level.clone(),
};
let message = IpcMessage::new(
"astrid.v1.approval",
request_payload,
Uuid::nil(), );
event_bus.publish(AstridEvent::Ipc {
message,
metadata: astrid_events::EventMetadata::default(),
});
tracing::debug!(
capsule = %capsule_id,
action = %guest_req.action,
resource = %guest_req.resource,
risk_level = %guest_req.risk_level,
%request_id,
"Published approval request, waiting for response"
);
let event = util::bounded_block_on_cancellable(
&runtime_handle,
&host_semaphore,
&cancel_token,
async {
tokio::time::timeout(
std::time::Duration::from_millis(MAX_APPROVAL_TIMEOUT_MS),
receiver.recv(),
)
.await
.ok()
.flatten()
},
)
.flatten();
let response_json = match event {
Some(event) => {
if let AstridEvent::Ipc { message, .. } = &*event {
match &message.payload {
IpcPayload::ApprovalResponse {
decision, reason, ..
} => {
let approved = matches!(
decision.as_str(),
"approve" | "approve_session" | "approve_always"
);
if approved && let Some(ref store) = allowance_store {
create_allowance_from_decision(
store,
&guest_req.action,
decision,
Some(workspace_root.clone()),
&capsule_id,
);
}
tracing::info!(
capsule = %capsule_id,
action = %guest_req.action,
%decision,
reason = reason.as_deref().unwrap_or("none"),
"Approval response received"
);
serde_json::to_vec(&serde_json::json!({
"approved": approved,
"decision": decision,
}))
.map_err(|e| Error::msg(format!("failed to serialize response: {e}")))?
},
_ => {
return Err(Error::msg(
"unexpected IPC payload type in approval response",
));
},
}
} else {
return Err(Error::msg("unexpected event type in approval response"));
}
},
None => {
tracing::warn!(
capsule = %capsule_id,
action = %guest_req.action,
"Approval request timed out or was cancelled"
);
serde_json::to_vec(&serde_json::json!({
"approved": false,
"decision": "deny",
}))
.map_err(|e| Error::msg(format!("failed to serialize response: {e}")))?
},
};
let mem = plugin.memory_new(&response_json)?;
outputs[0] = plugin.memory_to_val(mem);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guest_approval_request_deserializes() {
let json = r#"{"action":"git push","resource":"git push origin main","risk_level":"high"}"#;
let req: GuestApprovalRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.action, "git push");
assert_eq!(req.resource, "git push origin main");
assert_eq!(req.risk_level, "high");
}
#[test]
fn check_allowance_matches_command_pattern() {
let store = AllowanceStore::new();
let keypair = KeyPair::generate();
let allowance = Allowance {
id: AllowanceId::new(),
action_pattern: AllowancePattern::CommandPattern {
command: "git push *".into(),
},
created_at: Timestamp::now(),
expires_at: None,
max_uses: None,
uses_remaining: None,
session_only: true,
workspace_root: None,
signature: keypair.sign(b"test"),
};
store.add_allowance(allowance).unwrap();
assert!(check_allowance(&store, "git push origin main", None));
assert!(!check_allowance(&store, "git status", None));
}
#[test]
fn check_allowance_returns_false_on_empty_store() {
let store = AllowanceStore::new();
assert!(!check_allowance(&store, "git push origin main", None));
}
#[test]
fn create_allowance_approve_session() {
let store = AllowanceStore::new();
create_allowance_from_decision(&store, "git push", "approve_session", None, "test");
assert_eq!(store.count(), 1);
assert!(check_allowance(&store, "git push origin main", None));
}
#[test]
fn create_allowance_approve_always() {
let store = AllowanceStore::new();
create_allowance_from_decision(&store, "docker run", "approve_always", None, "test");
assert_eq!(store.count(), 1);
assert!(check_allowance(&store, "docker run my-image", None));
}
#[test]
fn create_allowance_simple_approve_does_nothing() {
let store = AllowanceStore::new();
create_allowance_from_decision(&store, "git push", "approve", None, "test");
assert_eq!(store.count(), 0);
}
#[test]
fn create_allowance_deny_does_nothing() {
let store = AllowanceStore::new();
create_allowance_from_decision(&store, "git push", "deny", None, "test");
assert_eq!(store.count(), 0);
}
#[test]
fn create_allowance_garbage_decision_does_nothing() {
let store = AllowanceStore::new();
create_allowance_from_decision(&store, "git push", "garbage", None, "test");
assert_eq!(store.count(), 0);
create_allowance_from_decision(&store, "git push", "", None, "test");
assert_eq!(store.count(), 0);
}
#[test]
fn check_allowance_with_special_characters() {
let store = AllowanceStore::new();
let keypair = KeyPair::generate();
let allowance = Allowance {
id: AllowanceId::new(),
action_pattern: AllowancePattern::CommandPattern {
command: "git push *".into(),
},
created_at: Timestamp::now(),
expires_at: None,
max_uses: None,
uses_remaining: None,
session_only: true,
workspace_root: None,
signature: keypair.sign(b"test"),
};
store.add_allowance(allowance).unwrap();
assert!(!check_allowance(&store, "git status; rm -rf /", None));
assert!(check_allowance(
&store,
"git push --force origin main",
None
));
}
#[test]
fn escape_glob_metacharacters_preserves_normal_chars() {
assert_eq!(escape_glob_metacharacters("git push"), "git push");
assert_eq!(
escape_glob_metacharacters("npm install @types/react"),
"npm install @types/react"
);
assert_eq!(escape_glob_metacharacters("my-tool_v2.0"), "my-tool_v2.0");
}
#[test]
fn escape_glob_metacharacters_escapes_wildcards() {
assert_eq!(escape_glob_metacharacters("*"), "\\*");
assert_eq!(escape_glob_metacharacters("git *"), "git \\*");
assert_eq!(escape_glob_metacharacters("git[status]"), "git\\[status\\]");
assert_eq!(escape_glob_metacharacters("cmd?"), "cmd\\?");
}
#[test]
fn create_allowance_with_wildcard_in_action_is_not_overly_broad() {
let store = AllowanceStore::new();
create_allowance_from_decision(&store, "*", "approve_session", None, "test");
assert_eq!(store.count(), 1);
assert!(!check_allowance(&store, "git push origin main", None));
}
#[test]
fn create_allowance_empty_action() {
let store = AllowanceStore::new();
create_allowance_from_decision(&store, "", "approve_session", None, "test");
assert_eq!(store.count(), 0);
assert!(!check_allowance(&store, "git push", None));
}
#[test]
fn approve_once_does_not_create_allowance() {
let store = AllowanceStore::new();
create_allowance_from_decision(&store, "git push", "approve", None, "test");
assert_eq!(store.count(), 0);
assert!(!check_allowance(&store, "git push origin main", None));
}
#[test]
fn sanitize_action_preserves_shell_fragments() {
assert_eq!(
sanitize_action_for_pattern("python -c 'print(\"hello\")'", "test"),
"python -c 'print(\"hello\")'"
);
assert_eq!(
sanitize_action_for_pattern("awk '{print $1}' file.txt", "test"),
"awk '{print $1}' file.txt"
);
assert_eq!(
sanitize_action_for_pattern("bash -c 'echo $HOME'", "test"),
"bash -c 'echo $HOME'"
);
assert_eq!(
sanitize_action_for_pattern("g++ main.cpp", "test"),
"g++ main.cpp"
);
assert_eq!(
sanitize_action_for_pattern("npm install @types/react", "test"),
"npm install @types/react"
);
assert_eq!(
sanitize_action_for_pattern("docker run ubuntu:latest", "test"),
"docker run ubuntu:latest"
);
}
#[test]
fn sanitize_action_preserves_glob_chars_for_escaping() {
assert_eq!(sanitize_action_for_pattern("*", "test"), "*");
assert_eq!(sanitize_action_for_pattern("git *", "test"), "git *");
assert_eq!(sanitize_action_for_pattern("cmd?", "test"), "cmd?");
assert_eq!(
sanitize_action_for_pattern("git[status]", "test"),
"git[status]"
);
}
#[test]
fn sanitize_action_strips_control_characters() {
assert_eq!(sanitize_action_for_pattern("git\0push", "test"), "gitpush");
assert_eq!(sanitize_action_for_pattern("git\rpush", "test"), "gitpush");
assert_eq!(
sanitize_action_for_pattern("git\x1b[31mpush", "test"),
"git[31mpush"
);
assert_eq!(sanitize_action_for_pattern("git\tpush", "test"), "gitpush");
assert_eq!(sanitize_action_for_pattern("git\npush", "test"), "gitpush");
}
#[test]
fn sanitize_action_truncates_long_strings() {
let long_action = "a".repeat(500);
let sanitized = sanitize_action_for_pattern(&long_action, "test");
assert_eq!(sanitized.chars().count(), MAX_ACTION_LEN);
}
#[test]
fn sanitize_action_exact_limit_no_change() {
let action = "a".repeat(MAX_ACTION_LEN);
let sanitized = sanitize_action_for_pattern(&action, "test");
assert_eq!(sanitized, action);
assert_eq!(sanitized.chars().count(), MAX_ACTION_LEN);
}
#[test]
fn sanitize_action_truncates_multibyte_chars() {
let action = "a".repeat(200) + &"\u{0100}".repeat(100);
assert_eq!(action.chars().count(), 300);
let sanitized = sanitize_action_for_pattern(&action, "test");
assert_eq!(sanitized.chars().count(), MAX_ACTION_LEN);
assert!(sanitized.starts_with(&"a".repeat(200)));
}
#[test]
fn sanitize_action_trims_whitespace() {
assert_eq!(
sanitize_action_for_pattern(" git push ", "test"),
"git push"
);
}
#[test]
fn create_allowance_whitespace_padded_action() {
let store = AllowanceStore::new();
create_allowance_from_decision(&store, " git push ", "approve_session", None, "test");
assert_eq!(store.count(), 1);
assert!(check_allowance(&store, "git push origin main", None));
assert!(!check_allowance(&store, "git status", None));
}
#[test]
fn create_allowance_combined_attack() {
let store = AllowanceStore::new();
let attack = "git\0 *\x1b[31m";
create_allowance_from_decision(&store, attack, "approve_session", None, "test");
assert_eq!(store.count(), 1);
assert!(!check_allowance(&store, "git push origin main", None));
assert!(!check_allowance(&store, "git status", None));
}
#[test]
fn create_allowance_null_byte_attack() {
let store = AllowanceStore::new();
create_allowance_from_decision(&store, "git\0push", "approve_session", None, "test");
assert_eq!(store.count(), 1);
assert!(!check_allowance(&store, "git push origin main", None));
assert!(check_allowance(&store, "gitpush something", None));
}
#[test]
fn sanitize_guest_field_strips_control_chars() {
let mut s = "git push\x1b[31m origin".to_string();
sanitize_guest_field(&mut s, MAX_RESOURCE_LEN, "resource", "test");
assert_eq!(s, "git push[31m origin");
}
#[test]
fn sanitize_guest_field_truncates_resource() {
let mut s = "a".repeat(2000);
sanitize_guest_field(&mut s, MAX_RESOURCE_LEN, "resource", "test");
assert_eq!(s.chars().count(), MAX_RESOURCE_LEN);
}
#[test]
fn sanitize_guest_field_resource_exact_limit() {
let original = "a".repeat(MAX_RESOURCE_LEN);
let mut s = original.clone();
sanitize_guest_field(&mut s, MAX_RESOURCE_LEN, "resource", "test");
assert_eq!(s, original);
}
#[test]
fn sanitize_guest_field_truncates_risk_level() {
let mut s = "x".repeat(200);
sanitize_guest_field(&mut s, MAX_RISK_LEVEL_LEN, "risk_level", "test");
assert_eq!(s.chars().count(), MAX_RISK_LEVEL_LEN);
}
#[test]
fn sanitize_guest_field_preserves_normal_risk_levels() {
for level in &["low", "medium", "high", "critical"] {
let mut s = level.to_string();
sanitize_guest_field(&mut s, MAX_RISK_LEVEL_LEN, "risk_level", "test");
assert_eq!(s, *level);
}
}
#[test]
fn sanitize_guest_field_truncates_multibyte() {
let mut s = "a".repeat(500) + &"\u{0100}".repeat(600);
assert_eq!(s.chars().count(), 1100);
sanitize_guest_field(&mut s, MAX_RESOURCE_LEN, "resource", "test");
assert_eq!(s.chars().count(), MAX_RESOURCE_LEN);
assert!(s.starts_with(&"a".repeat(500)));
}
#[test]
fn sanitize_guest_field_trims_whitespace() {
let mut s = " git push origin ".to_string();
sanitize_guest_field(&mut s, MAX_RESOURCE_LEN, "resource", "test");
assert_eq!(s, "git push origin");
}
#[test]
fn sanitize_guest_field_combined_attack() {
let mut s = format!("{}\x1b[31m{}", "A".repeat(1000), "B".repeat(1000));
sanitize_guest_field(&mut s, MAX_RESOURCE_LEN, "resource", "test");
assert_eq!(s.chars().count(), MAX_RESOURCE_LEN);
assert!(s.chars().all(|c| !c.is_control()));
}
#[test]
fn sanitize_guest_field_empty_string() {
let mut s = String::new();
sanitize_guest_field(&mut s, MAX_RESOURCE_LEN, "resource", "test");
assert!(s.is_empty());
}
}