feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "boosapi-agent"
version = "0.1.0"
edition = "2021"
[dependencies]
open-agent-sdk = { path = "../open-agent-sdk" }
boosapi-core = { path = "../core" }
boosapi-providers = { path = "../providers" }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
async-trait = "0.1"
tracing = "0.1"
tauri = "2"
futures = "0.3"
@@ -0,0 +1,609 @@
//! BoosApiProviderBridge: adapts BoosApi's ProviderAdapter to the SDK's LLMProvider trait.
use async_trait::async_trait;
use futures::StreamExt;
use open_agent_sdk::api::provider::ProviderRequest;
use open_agent_sdk::api::ApiError;
use open_agent_sdk::types::{ImageContentSource, ToolResultContentBlock};
use open_agent_sdk::{
ApiType, ContentBlock, LLMProvider, Message, MessageRole, ProviderResponse, SDKMessage, Usage,
};
use boosapi_core::types::{
ChatContent, ChatMessage, ChatRequest, ChatTool, ChatToolFunction, ContentPart, ImageUrl,
ModelParamOverrides, TokenUsage, ToolCall, ToolCallFunction,
};
use boosapi_providers::{ProviderAdapter, ProviderRequestContext};
use serde_json::Value;
use std::sync::Arc;
/// Bridge between BoosApi providers and the open-agent-sdk LLMProvider interface.
pub struct BoosApiProviderBridge {
adapter: Arc<dyn ProviderAdapter>,
ctx: ProviderRequestContext,
api_type: ApiType,
model_param_overrides: Option<ModelParamOverrides>,
app: Option<tauri::AppHandle>,
conversation_id: Option<String>,
}
impl BoosApiProviderBridge {
pub fn new(
adapter: Arc<dyn ProviderAdapter>,
ctx: ProviderRequestContext,
provider_type: &str,
) -> Result<Self, String> {
let api_type = match provider_type {
"anthropic" => ApiType::AnthropicMessages,
"openai" => ApiType::OpenAICompletions,
"gemini" => ApiType::OpenAICompletions,
"custom" => ApiType::OpenAICompletions,
"openai_responses" => ApiType::OpenAICompletions,
"deepseek" => ApiType::OpenAICompletions,
"xai" => ApiType::OpenAICompletions,
"glm" => ApiType::OpenAICompletions,
"siliconflow" => ApiType::OpenAICompletions,
other => {
tracing::warn!(
"Unknown provider type '{}', defaulting to OpenAI compat",
other
);
ApiType::OpenAICompletions
}
};
Ok(Self {
adapter,
ctx,
api_type,
model_param_overrides: None,
app: None,
conversation_id: None,
})
}
pub fn with_model_param_overrides(mut self, overrides: Option<ModelParamOverrides>) -> Self {
self.model_param_overrides = overrides;
self
}
/// Attach a Tauri AppHandle for streaming text chunks to the frontend.
pub fn with_app(mut self, app: tauri::AppHandle, conversation_id: String) -> Self {
self.app = Some(app);
self.conversation_id = Some(conversation_id);
self
}
}
#[async_trait]
impl LLMProvider for BoosApiProviderBridge {
fn api_type(&self) -> ApiType {
self.api_type.clone()
}
async fn create_message(
&self,
request: ProviderRequest<'_>,
stream_tx: Option<tokio::sync::mpsc::Sender<SDKMessage>>,
) -> Result<ProviderResponse, ApiError> {
let chat_request = convert_request(request, self.model_param_overrides.as_ref());
let mut stream = self.adapter.chat_stream(&self.ctx, chat_request);
let mut accumulated_text = String::new();
let mut accumulated_thinking = String::new();
let mut final_tool_calls: Option<Vec<ToolCall>> = None;
let mut final_usage: Option<TokenUsage> = None;
while let Some(result) = stream.next().await {
match result {
Ok(chunk) => {
if let Some(ref text) = chunk.content {
if !text.is_empty() {
accumulated_text.push_str(text);
// Emit streaming text delta via SDK channel
// (agent.rs will forward to frontend as agent-stream-text)
if let Some(ref tx) = stream_tx {
let _ = tx.try_send(SDKMessage::TextDelta { text: text.clone() });
}
}
}
if let Some(ref thinking) = chunk.thinking {
if !thinking.is_empty() {
accumulated_thinking.push_str(thinking);
// Emit streaming thinking delta via SDK channel
// (agent.rs will forward to frontend as agent-stream-thinking)
if let Some(ref tx) = stream_tx {
let _ = tx.try_send(SDKMessage::ThinkingDelta {
thinking: thinking.clone(),
});
}
}
}
if chunk.tool_calls.is_some() {
final_tool_calls.clone_from(&chunk.tool_calls);
}
if chunk.usage.is_some() {
final_usage.clone_from(&chunk.usage);
}
if chunk.done {
break;
}
}
Err(e) => {
return Err(classify_provider_error(e));
}
}
}
let usage = final_usage.unwrap_or(TokenUsage {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
});
let response = boosapi_core::types::ChatResponse {
id: String::new(),
model: String::new(),
content: accumulated_text,
thinking: if accumulated_thinking.is_empty() {
None
} else {
Some(accumulated_thinking)
},
usage,
tool_calls: final_tool_calls,
};
Ok(convert_response(response))
}
}
// ---------------------------------------------------------------------------
// SDK ProviderRequest → BoosApi ChatRequest
// ---------------------------------------------------------------------------
fn convert_request(
request: ProviderRequest<'_>,
model_param_overrides: Option<&ModelParamOverrides>,
) -> ChatRequest {
let messages: Vec<ChatMessage> = request
.messages
.iter()
.flat_map(convert_sdk_message_to_chat_messages)
.collect();
let tools: Option<Vec<ChatTool>> = request.tools.as_ref().map(|tools| {
tools
.iter()
.map(|t| ChatTool {
r#type: "function".to_string(),
function: ChatToolFunction {
name: t.name.clone(),
description: Some(t.description.clone()),
parameters: Some(t.input_schema.clone()),
},
})
.collect()
});
let system_text: Option<String> = request.system.as_ref().map(|blocks| {
blocks
.iter()
.map(|b| b.text.as_str())
.collect::<Vec<_>>()
.join("\n\n")
});
let mut final_messages = Vec::new();
if let Some(sys) = system_text {
final_messages.push(ChatMessage {
role: "system".to_string(),
content: ChatContent::Text(sys),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
});
}
final_messages.extend(messages);
let force_model_max_tokens = model_param_overrides
.and_then(|overrides| overrides.force_max_tokens)
== Some(true);
let max_tokens = if request.max_tokens > 0 {
Some(request.max_tokens as u32)
} else if force_model_max_tokens {
model_param_overrides
.and_then(|overrides| overrides.max_tokens)
.or(Some(4096))
} else {
None
};
ChatRequest {
model: request.model.to_string(),
messages: final_messages,
stream: true,
temperature: None,
top_p: None,
max_tokens,
tools,
thinking_budget: request
.thinking
.as_ref()
.and_then(|t| t.budget_tokens.map(|b| b as u32)),
thinking_level: None,
reasoning_profile: model_param_overrides
.and_then(|overrides| overrides.reasoning_profile.clone()),
use_max_completion_tokens: model_param_overrides
.and_then(|overrides| overrides.use_max_completion_tokens),
thinking_param_style: model_param_overrides
.and_then(|overrides| overrides.thinking_param_style.clone()),
extra_body: model_param_overrides.and_then(|overrides| overrides.extra_body.clone()),
}
}
/// Convert a single SDK Message into one or more BoosApi ChatMessages.
/// ToolResult content blocks become separate tool-role messages.
fn convert_sdk_message_to_chat_messages(msg: &Message) -> Vec<ChatMessage> {
let role = match msg.role {
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
};
let mut text_parts: Vec<String> = Vec::new();
let mut thinking_parts: Vec<String> = Vec::new();
let mut tool_calls: Vec<ToolCall> = Vec::new();
let mut tool_results: Vec<(String, String)> = Vec::new();
let mut image_parts: Vec<ImageContentSource> = Vec::new();
for block in &msg.content {
match block {
ContentBlock::Text { text } => {
text_parts.push(text.clone());
}
ContentBlock::ToolUse { id, name, input } => {
tool_calls.push(ToolCall {
id: id.clone(),
call_type: "function".to_string(),
function: ToolCallFunction {
name: name.clone(),
arguments: serde_json::to_string(input).unwrap_or_default(),
},
});
}
ContentBlock::ToolResult {
tool_use_id,
content,
is_error: _,
} => {
let text = content
.iter()
.filter_map(|c| match c {
ToolResultContentBlock::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
tool_results.push((tool_use_id.clone(), text));
}
ContentBlock::Thinking { thinking, .. } => {
if msg.role == MessageRole::Assistant && !thinking.is_empty() {
thinking_parts.push(thinking.clone());
}
}
ContentBlock::Image { source } => {
image_parts.push(source.clone());
}
}
}
let mut result = Vec::new();
if role == "assistant" {
let content = if text_parts.is_empty() {
ChatContent::Text(String::new())
} else {
ChatContent::Text(text_parts.join(""))
};
result.push(ChatMessage {
role: "assistant".to_string(),
content,
reasoning_content: if thinking_parts.is_empty() {
None
} else {
Some(thinking_parts.join(""))
},
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
tool_call_id: None,
});
} else if !tool_results.is_empty() {
for (tool_use_id, text) in tool_results {
result.push(ChatMessage {
role: "tool".to_string(),
content: ChatContent::Text(text),
reasoning_content: None,
tool_calls: None,
tool_call_id: Some(tool_use_id),
});
}
} else {
let content = if !image_parts.is_empty() {
let mut parts: Vec<ContentPart> = text_parts
.iter()
.map(|t| ContentPart {
r#type: "text".to_string(),
text: Some(t.clone()),
image_url: None,
})
.collect();
for img in &image_parts {
parts.push(ContentPart {
r#type: "image_url".to_string(),
text: None,
image_url: Some(ImageUrl {
url: format!("data:{};base64,{}", img.media_type, img.data),
}),
});
}
ChatContent::Multipart(parts)
} else {
ChatContent::Text(text_parts.join(""))
};
result.push(ChatMessage {
role: role.to_string(),
content,
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
});
}
result
}
// ---------------------------------------------------------------------------
// BoosApi ChatResponse → SDK ProviderResponse
// ---------------------------------------------------------------------------
fn convert_response(response: boosapi_core::types::ChatResponse) -> ProviderResponse {
let mut content_blocks: Vec<ContentBlock> = Vec::new();
if let Some(thinking) = &response.thinking {
if !thinking.is_empty() {
content_blocks.push(ContentBlock::Thinking {
thinking: thinking.clone(),
signature: None,
});
}
}
if !response.content.is_empty() {
content_blocks.push(ContentBlock::Text {
text: response.content.clone(),
});
}
if let Some(tool_calls) = &response.tool_calls {
for tc in tool_calls {
let input: Value = serde_json::from_str(&tc.function.arguments).unwrap_or(Value::Null);
content_blocks.push(ContentBlock::ToolUse {
id: tc.id.clone(),
name: tc.function.name.clone(),
input,
});
}
}
let stop_reason = if response
.tool_calls
.as_ref()
.map_or(false, |tc| !tc.is_empty())
{
Some("tool_use".to_string())
} else {
Some("end_turn".to_string())
};
ProviderResponse {
message: Message {
role: MessageRole::Assistant,
content: content_blocks,
},
usage: Usage {
input_tokens: response.usage.prompt_tokens as u64,
output_tokens: response.usage.completion_tokens as u64,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
stop_reason,
}
}
// ---------------------------------------------------------------------------
// Error classification
// ---------------------------------------------------------------------------
/// Parse an HTTP status code from provider error messages like "OpenAI API error 400 Bad Request: ..."
fn parse_http_status(err: &str) -> Option<u16> {
for pattern in &["API error ", "error "] {
if let Some(pos) = err.find(pattern) {
let after = &err[pos + pattern.len()..];
if let Some(end) = after.find(|c: char| !c.is_ascii_digit()) {
if end > 0 {
if let Ok(status) = after[..end].parse::<u16>() {
if (100..600).contains(&status) {
return Some(status);
}
}
}
}
}
}
None
}
/// Classify a provider error into the appropriate SDK ApiError variant,
/// so the retry logic only retries truly transient errors.
fn classify_provider_error(e: boosapi_core::error::BoosApiError) -> ApiError {
let err_str = e.to_string();
if let Some(status) = parse_http_status(&err_str) {
if status == 401 || status == 403 {
ApiError::AuthError(err_str)
} else if status == 429 {
ApiError::RateLimitError
} else if (400..500).contains(&status) {
// Client errors (400, 404, 422, etc.) are NOT retryable
ApiError::HttpError {
status,
message: err_str,
}
} else {
// 5xx errors are retryable via NetworkError mapping
ApiError::NetworkError(err_str)
}
} else {
ApiError::NetworkError(err_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
use open_agent_sdk::api::provider::ProviderRequest;
fn param_overrides() -> ModelParamOverrides {
ModelParamOverrides {
temperature: None,
max_tokens: Some(2048),
top_p: None,
frequency_penalty: None,
use_max_completion_tokens: Some(false),
no_system_role: None,
force_max_tokens: None,
thinking_param_style: Some("enable_thinking".to_string()),
reasoning_profile: Some("siliconflow_enable_thinking".to_string()),
reasoning_options: None,
reasoning_default: None,
extra_body: None,
}
}
#[test]
fn convert_request_applies_non_token_model_param_overrides() {
let messages = vec![Message {
role: MessageRole::User,
content: vec![ContentBlock::Text {
text: "hello".to_string(),
}],
}];
let request = ProviderRequest {
model: "deepseek-reasoner",
max_tokens: 0,
messages: &messages,
system: None,
tools: None,
thinking: None,
};
let converted = convert_request(request, Some(&param_overrides()));
assert_eq!(converted.max_tokens, None);
assert_eq!(
converted.reasoning_profile.as_deref(),
Some("siliconflow_enable_thinking")
);
assert_eq!(converted.use_max_completion_tokens, Some(false));
assert_eq!(
converted.thinking_param_style.as_deref(),
Some("enable_thinking")
);
}
#[test]
fn convert_request_uses_model_max_tokens_only_when_forced() {
let messages = vec![Message {
role: MessageRole::User,
content: vec![ContentBlock::Text {
text: "hello".to_string(),
}],
}];
let request = ProviderRequest {
model: "deepseek-reasoner",
max_tokens: 0,
messages: &messages,
system: None,
tools: None,
thinking: None,
};
let mut overrides = param_overrides();
overrides.force_max_tokens = Some(true);
let converted = convert_request(request, Some(&overrides));
assert_eq!(converted.max_tokens, Some(2048));
}
#[test]
fn convert_request_prefers_sdk_max_tokens_over_model_default() {
let messages = vec![Message {
role: MessageRole::User,
content: vec![ContentBlock::Text {
text: "hello".to_string(),
}],
}];
let request = ProviderRequest {
model: "deepseek-reasoner",
max_tokens: 8192,
messages: &messages,
system: None,
tools: None,
thinking: None,
};
let converted = convert_request(request, Some(&param_overrides()));
assert_eq!(converted.max_tokens, Some(8192));
}
#[test]
fn assistant_thinking_block_becomes_reasoning_content() {
let message = Message {
role: MessageRole::Assistant,
content: vec![
ContentBlock::Thinking {
thinking: "hidden reasoning".to_string(),
signature: None,
},
ContentBlock::Text {
text: "final answer".to_string(),
},
ContentBlock::ToolUse {
id: "call-1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "README.md"}),
},
],
};
let converted = convert_sdk_message_to_chat_messages(&message);
assert_eq!(converted.len(), 1);
assert_eq!(
converted[0].reasoning_content.as_deref(),
Some("hidden reasoning")
);
assert!(converted[0].tool_calls.is_some());
}
}
@@ -0,0 +1,3 @@
pub mod bridge;
pub mod permission;
pub mod security;
@@ -0,0 +1,108 @@
use serde::{Deserialize, Serialize};
/// Tool risk classification
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RiskLevel {
ReadOnly,
Write,
Execute,
}
/// Permission mode for the agent session
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PermissionMode {
Default,
AcceptEdits,
FullAccess,
}
impl PermissionMode {
pub fn from_str(s: &str) -> Self {
match s {
"accept_edits" => Self::AcceptEdits,
"full_access" => Self::FullAccess,
_ => Self::Default,
}
}
}
/// What the permission system decides
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionAction {
AutoAllow,
RequireApproval,
HardDeny,
}
/// Classify a tool's risk level based on its name
pub fn classify_tool_risk(tool_name: &str) -> RiskLevel {
let name_lower = tool_name.to_lowercase();
// Execute-level tools
if matches!(
name_lower.as_str(),
"bash" | "shell" | "run_command" | "execute"
) || name_lower.contains("exec")
|| name_lower.contains("run")
|| name_lower.contains("bash")
|| name_lower.contains("shell")
{
return RiskLevel::Execute;
}
// Write-level tools
if matches!(
name_lower.as_str(),
"write"
| "edit"
| "create"
| "delete"
| "rename"
| "patch"
| "write_file"
| "edit_file"
| "create_file"
| "delete_file"
| "move"
| "mkdir"
| "remove"
) || name_lower.contains("write")
|| name_lower.contains("edit")
|| name_lower.contains("create")
|| name_lower.contains("delete")
|| name_lower.contains("patch")
{
return RiskLevel::Write;
}
// Everything else is read-only
RiskLevel::ReadOnly
}
/// Decision matrix: given permission mode, risk level, and whether the tool
/// is in the "always allowed" set, return the action to take.
pub fn decide_permission(
mode: PermissionMode,
risk: RiskLevel,
is_always_allowed: bool,
) -> PermissionAction {
// If tool was previously approved with "always allow", auto-allow
if is_always_allowed {
return PermissionAction::AutoAllow;
}
match (mode, risk) {
// Default mode: only read is auto-allowed
(PermissionMode::Default, RiskLevel::ReadOnly) => PermissionAction::AutoAllow,
(PermissionMode::Default, RiskLevel::Write) => PermissionAction::RequireApproval,
(PermissionMode::Default, RiskLevel::Execute) => PermissionAction::RequireApproval,
// Accept edits: read + write auto-allowed
(PermissionMode::AcceptEdits, RiskLevel::ReadOnly) => PermissionAction::AutoAllow,
(PermissionMode::AcceptEdits, RiskLevel::Write) => PermissionAction::AutoAllow,
(PermissionMode::AcceptEdits, RiskLevel::Execute) => PermissionAction::RequireApproval,
// Full access: everything auto-allowed
(PermissionMode::FullAccess, _) => PermissionAction::AutoAllow,
}
}
@@ -0,0 +1,189 @@
use open_agent_sdk::PermissionDecision;
use serde_json::Value;
use std::path::{Path, PathBuf};
/// Validate that a path resolves to within the cwd.
/// For existing paths, uses canonicalize (resolves symlinks).
/// For non-existing paths, validates the parent directory.
pub fn validate_path_within_cwd(path: &str, cwd: &str) -> Result<PathBuf, String> {
let target = Path::new(path);
let cwd_path = Path::new(cwd);
// Canonicalize cwd (must exist)
let cwd_canonical = cwd_path
.canonicalize()
.map_err(|e| format!("Cannot resolve working directory '{}': {}", cwd, e))?;
// Try to resolve the target path
let resolved = if target.is_absolute() {
target.to_path_buf()
} else {
cwd_path.join(target)
};
// If path exists, canonicalize it (resolves symlinks)
if resolved.exists() {
let canonical = resolved
.canonicalize()
.map_err(|e| format!("Cannot resolve path '{}': {}", path, e))?;
if canonical.starts_with(&cwd_canonical) {
Ok(canonical)
} else {
Err(format!(
"Path '{}' resolves to '{}' which is outside working directory '{}'",
path,
canonical.display(),
cwd_canonical.display()
))
}
} else {
// For non-existing paths, check parent directory
if let Some(parent) = resolved.parent() {
if parent.exists() {
let parent_canonical = parent
.canonicalize()
.map_err(|e| format!("Cannot resolve parent of '{}': {}", path, e))?;
if parent_canonical.starts_with(&cwd_canonical) {
Ok(resolved)
} else {
Err(format!(
"Parent of '{}' resolves outside working directory '{}'",
path,
cwd_canonical.display()
))
}
} else {
// Parent doesn't exist — walk up until we find an existing ancestor
let mut ancestor = parent.to_path_buf();
loop {
if ancestor.exists() {
let anc_canonical = ancestor
.canonicalize()
.map_err(|e| format!("Cannot resolve ancestor: {}", e))?;
if anc_canonical.starts_with(&cwd_canonical) {
return Ok(resolved);
} else {
return Err(format!(
"Path '{}' is outside working directory '{}'",
path,
cwd_canonical.display()
));
}
}
if !ancestor.pop() {
return Err(format!(
"Cannot verify path '{}' against working directory",
path
));
}
}
}
} else {
Err(format!("Invalid path: '{}'", path))
}
}
}
/// Check if a tool's path arguments are safe (within cwd).
/// Returns Some(PermissionDecision::Deny(reason)) if unsafe, None if safe or not applicable.
pub fn check_path_safety(tool_name: &str, input: &Value, cwd: &str) -> Option<PermissionDecision> {
let name_lower = tool_name.to_lowercase();
match name_lower.as_str() {
// Single-file path tools
"read" | "read_file" | "write" | "write_file" | "edit" | "edit_file" | "create"
| "create_file" | "delete" | "delete_file" | "rename" | "list_dir" | "listdir" => {
check_single_path(input, cwd)
}
// Glob tool — check base directory of pattern
"glob" | "glob_search" => check_glob_path(input, cwd),
// Grep tool — optional path
"grep" | "search" | "ripgrep" => check_grep_path(input, cwd),
// Bash/shell — no path checking (cwd is injected, but cd escape is Phase 4)
"bash" | "shell" | "run_command" | "execute" => None,
// Unknown tools — don't block
_ => None,
}
}
fn check_single_path(input: &Value, cwd: &str) -> Option<PermissionDecision> {
// Try common field names for path
let path_str = input
.get("path")
.or_else(|| input.get("file_path"))
.or_else(|| input.get("file"))
.and_then(|v| v.as_str());
if let Some(path) = path_str {
if let Err(reason) = validate_path_within_cwd(path, cwd) {
return Some(PermissionDecision::Deny(reason));
}
}
// Also check "new_path" for rename operations
if let Some(new_path) = input.get("new_path").and_then(|v| v.as_str()) {
if let Err(reason) = validate_path_within_cwd(new_path, cwd) {
return Some(PermissionDecision::Deny(reason));
}
}
None
}
fn check_glob_path(input: &Value, cwd: &str) -> Option<PermissionDecision> {
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
if let Err(reason) = validate_path_within_cwd(path, cwd) {
return Some(PermissionDecision::Deny(reason));
}
}
let pattern = input
.get("pattern")
.or_else(|| input.get("glob"))
.and_then(|v| v.as_str())?;
// Extract base directory from glob pattern (non-wildcard prefix)
let base_dir = extract_glob_base(pattern);
if !base_dir.is_empty() {
if let Err(reason) = validate_path_within_cwd(&base_dir, cwd) {
return Some(PermissionDecision::Deny(reason));
}
}
// Empty base_dir means pattern starts with wildcard → relative to cwd → safe
None
}
fn check_grep_path(input: &Value, cwd: &str) -> Option<PermissionDecision> {
// Grep path is optional — if not specified, defaults to cwd (safe)
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
if let Err(reason) = validate_path_within_cwd(path, cwd) {
return Some(PermissionDecision::Deny(reason));
}
}
None
}
/// Extract the non-wildcard prefix from a glob pattern as a base directory.
/// e.g., "src/components/**/*.tsx" → "src/components"
/// "**/*.rs" → ""
/// "/absolute/path/to/*.txt" → "/absolute/path/to"
fn extract_glob_base(pattern: &str) -> String {
let mut parts = Vec::new();
for segment in pattern.split('/') {
if segment.contains('*')
|| segment.contains('?')
|| segment.contains('[')
|| segment.contains('{')
{
break;
}
parts.push(segment);
}
parts.join("/")
}