zeroclaw/src/tools/microsoft365/mod.rs
Giulio V c7f341885b fix(microsoft365): scope token cache, fail on unimplemented encryption, use config dir
- Include hash of (tenant_id, client_id, auth_flow) in token cache
  filename to prevent cross-account token reuse after config changes.
- Fail fast with a clear error when token_cache_encrypted is true,
  since encryption is not yet implemented (was silently storing
  plaintext).
- Use the config directory (parent of config.toml) instead of
  workspace_dir for token cache storage, keeping bearer tokens out
  of the project tree.
2026-03-15 15:33:42 +01:00

567 lines
19 KiB
Rust

//! Microsoft 365 integration tool — Graph API access for Mail, Teams, Calendar,
//! OneDrive, and SharePoint via a single action-dispatched tool surface.
//!
//! Auth is handled through direct HTTP calls to the Microsoft identity platform
//! (client credentials or device code flow) with token caching.
pub mod auth;
pub mod graph_client;
pub mod types;
use crate::security::policy::ToolOperation;
use crate::security::SecurityPolicy;
use crate::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
/// Maximum download size for OneDrive files (10 MB).
const MAX_ONEDRIVE_DOWNLOAD_SIZE: usize = 10 * 1024 * 1024;
/// Default number of items to return in list operations.
const DEFAULT_TOP: u32 = 25;
pub struct Microsoft365Tool {
config: types::Microsoft365ResolvedConfig,
security: Arc<SecurityPolicy>,
token_cache: Arc<auth::TokenCache>,
http_client: reqwest::Client,
}
impl Microsoft365Tool {
pub fn new(
config: types::Microsoft365ResolvedConfig,
security: Arc<SecurityPolicy>,
zeroclaw_dir: &std::path::Path,
) -> anyhow::Result<Self> {
let http_client =
crate::config::build_runtime_proxy_client_with_timeouts("tool.microsoft365", 60, 10);
let token_cache = Arc::new(auth::TokenCache::new(config.clone(), zeroclaw_dir)?);
Ok(Self {
config,
security,
token_cache,
http_client,
})
}
async fn get_token(&self) -> anyhow::Result<String> {
self.token_cache.get_token(&self.http_client).await
}
fn user_id(&self) -> &str {
&self.config.user_id
}
async fn dispatch(&self, action: &str, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
match action {
"mail_list" => self.handle_mail_list(args).await,
"mail_send" => self.handle_mail_send(args).await,
"teams_message_list" => self.handle_teams_message_list(args).await,
"teams_message_send" => self.handle_teams_message_send(args).await,
"calendar_events_list" => self.handle_calendar_events_list(args).await,
"calendar_event_create" => self.handle_calendar_event_create(args).await,
"calendar_event_delete" => self.handle_calendar_event_delete(args).await,
"onedrive_list" => self.handle_onedrive_list(args).await,
"onedrive_download" => self.handle_onedrive_download(args).await,
"sharepoint_search" => self.handle_sharepoint_search(args).await,
_ => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Unknown action: {action}")),
}),
}
}
// ── Read actions ────────────────────────────────────────────────
async fn handle_mail_list(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
self.security
.enforce_tool_operation(ToolOperation::Read, "microsoft365.mail_list")
.map_err(|e| anyhow::anyhow!(e))?;
let token = self.get_token().await?;
let folder = args["folder"].as_str();
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
.unwrap_or(DEFAULT_TOP);
let result =
graph_client::mail_list(&self.http_client, &token, self.user_id(), folder, top).await?;
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&result)?,
error: None,
})
}
async fn handle_teams_message_list(
&self,
args: &serde_json::Value,
) -> anyhow::Result<ToolResult> {
self.security
.enforce_tool_operation(ToolOperation::Read, "microsoft365.teams_message_list")
.map_err(|e| anyhow::anyhow!(e))?;
let token = self.get_token().await?;
let team_id = args["team_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("team_id is required"))?;
let channel_id = args["channel_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("channel_id is required"))?;
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
.unwrap_or(DEFAULT_TOP);
let result =
graph_client::teams_message_list(&self.http_client, &token, team_id, channel_id, top)
.await?;
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&result)?,
error: None,
})
}
async fn handle_calendar_events_list(
&self,
args: &serde_json::Value,
) -> anyhow::Result<ToolResult> {
self.security
.enforce_tool_operation(ToolOperation::Read, "microsoft365.calendar_events_list")
.map_err(|e| anyhow::anyhow!(e))?;
let token = self.get_token().await?;
let start = args["start"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("start datetime is required"))?;
let end = args["end"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("end datetime is required"))?;
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
.unwrap_or(DEFAULT_TOP);
let result = graph_client::calendar_events_list(
&self.http_client,
&token,
self.user_id(),
start,
end,
top,
)
.await?;
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&result)?,
error: None,
})
}
async fn handle_onedrive_list(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
self.security
.enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_list")
.map_err(|e| anyhow::anyhow!(e))?;
let token = self.get_token().await?;
let path = args["path"].as_str();
let result =
graph_client::onedrive_list(&self.http_client, &token, self.user_id(), path).await?;
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&result)?,
error: None,
})
}
async fn handle_onedrive_download(
&self,
args: &serde_json::Value,
) -> anyhow::Result<ToolResult> {
self.security
.enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_download")
.map_err(|e| anyhow::anyhow!(e))?;
let token = self.get_token().await?;
let item_id = args["item_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("item_id is required"))?;
let max_size = args["max_size"]
.as_u64()
.and_then(|v| usize::try_from(v).ok())
.unwrap_or(MAX_ONEDRIVE_DOWNLOAD_SIZE);
let bytes = graph_client::onedrive_download(
&self.http_client,
&token,
self.user_id(),
item_id,
max_size,
)
.await?;
// Return base64-encoded for binary safety.
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(ToolResult {
success: true,
output: format!(
"Downloaded {} bytes (base64 encoded):\n{encoded}",
bytes.len()
),
error: None,
})
}
async fn handle_sharepoint_search(
&self,
args: &serde_json::Value,
) -> anyhow::Result<ToolResult> {
self.security
.enforce_tool_operation(ToolOperation::Read, "microsoft365.sharepoint_search")
.map_err(|e| anyhow::anyhow!(e))?;
let token = self.get_token().await?;
let query = args["query"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("query is required"))?;
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
.unwrap_or(DEFAULT_TOP);
let result = graph_client::sharepoint_search(&self.http_client, &token, query, top).await?;
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&result)?,
error: None,
})
}
// ── Write actions ───────────────────────────────────────────────
async fn handle_mail_send(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
self.security
.enforce_tool_operation(ToolOperation::Act, "microsoft365.mail_send")
.map_err(|e| anyhow::anyhow!(e))?;
let token = self.get_token().await?;
let to: Vec<String> = args["to"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("to must be an array of email addresses"))?
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
if to.is_empty() {
anyhow::bail!("to must contain at least one email address");
}
let subject = args["subject"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("subject is required"))?;
let body = args["body"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("body is required"))?;
graph_client::mail_send(
&self.http_client,
&token,
self.user_id(),
&to,
subject,
body,
)
.await?;
Ok(ToolResult {
success: true,
output: format!("Email sent to: {}", to.join(", ")),
error: None,
})
}
async fn handle_teams_message_send(
&self,
args: &serde_json::Value,
) -> anyhow::Result<ToolResult> {
self.security
.enforce_tool_operation(ToolOperation::Act, "microsoft365.teams_message_send")
.map_err(|e| anyhow::anyhow!(e))?;
let token = self.get_token().await?;
let team_id = args["team_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("team_id is required"))?;
let channel_id = args["channel_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("channel_id is required"))?;
let body = args["body"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("body is required"))?;
graph_client::teams_message_send(&self.http_client, &token, team_id, channel_id, body)
.await?;
Ok(ToolResult {
success: true,
output: "Teams message sent".to_string(),
error: None,
})
}
async fn handle_calendar_event_create(
&self,
args: &serde_json::Value,
) -> anyhow::Result<ToolResult> {
self.security
.enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_create")
.map_err(|e| anyhow::anyhow!(e))?;
let token = self.get_token().await?;
let subject = args["subject"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("subject is required"))?;
let start = args["start"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("start datetime is required"))?;
let end = args["end"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("end datetime is required"))?;
let attendees: Vec<String> = args["attendees"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let body_text = args["body"].as_str();
let event_id = graph_client::calendar_event_create(
&self.http_client,
&token,
self.user_id(),
subject,
start,
end,
&attendees,
body_text,
)
.await?;
Ok(ToolResult {
success: true,
output: format!("Calendar event created (id: {event_id})"),
error: None,
})
}
async fn handle_calendar_event_delete(
&self,
args: &serde_json::Value,
) -> anyhow::Result<ToolResult> {
self.security
.enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_delete")
.map_err(|e| anyhow::anyhow!(e))?;
let token = self.get_token().await?;
let event_id = args["event_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("event_id is required"))?;
graph_client::calendar_event_delete(&self.http_client, &token, self.user_id(), event_id)
.await?;
Ok(ToolResult {
success: true,
output: format!("Calendar event {event_id} deleted"),
error: None,
})
}
}
#[async_trait]
impl Tool for Microsoft365Tool {
fn name(&self) -> &str {
"microsoft365"
}
fn description(&self) -> &str {
"Microsoft 365 integration: manage Outlook mail, Teams messages, Calendar events, \
OneDrive files, and SharePoint search via Microsoft Graph API"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["action"],
"properties": {
"action": {
"type": "string",
"enum": [
"mail_list",
"mail_send",
"teams_message_list",
"teams_message_send",
"calendar_events_list",
"calendar_event_create",
"calendar_event_delete",
"onedrive_list",
"onedrive_download",
"sharepoint_search"
],
"description": "The Microsoft 365 action to perform"
},
"folder": {
"type": "string",
"description": "Mail folder ID (for mail_list, e.g. 'inbox', 'sentitems')"
},
"to": {
"type": "array",
"items": { "type": "string" },
"description": "Recipient email addresses (for mail_send)"
},
"subject": {
"type": "string",
"description": "Email subject or calendar event subject"
},
"body": {
"type": "string",
"description": "Message body text"
},
"team_id": {
"type": "string",
"description": "Teams team ID (for teams_message_list/send)"
},
"channel_id": {
"type": "string",
"description": "Teams channel ID (for teams_message_list/send)"
},
"start": {
"type": "string",
"description": "Start datetime in ISO 8601 format (for calendar actions)"
},
"end": {
"type": "string",
"description": "End datetime in ISO 8601 format (for calendar actions)"
},
"attendees": {
"type": "array",
"items": { "type": "string" },
"description": "Attendee email addresses (for calendar_event_create)"
},
"event_id": {
"type": "string",
"description": "Calendar event ID (for calendar_event_delete)"
},
"path": {
"type": "string",
"description": "OneDrive folder path (for onedrive_list)"
},
"item_id": {
"type": "string",
"description": "OneDrive item ID (for onedrive_download)"
},
"max_size": {
"type": "integer",
"description": "Maximum download size in bytes (for onedrive_download, default 10MB)"
},
"query": {
"type": "string",
"description": "Search query (for sharepoint_search)"
},
"top": {
"type": "integer",
"description": "Maximum number of items to return (default 25)"
}
}
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let action = match args["action"].as_str() {
Some(a) => a.to_string(),
None => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'action' parameter is required".to_string()),
});
}
};
match self.dispatch(&action, &args).await {
Ok(result) => Ok(result),
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("microsoft365.{action} failed: {e}")),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_name_is_microsoft365() {
// Verify the schema is valid JSON with the expected structure.
let schema_str = r#"{"type":"object","required":["action"]}"#;
let _: serde_json::Value = serde_json::from_str(schema_str).unwrap();
}
#[test]
fn parameters_schema_has_action_enum() {
let schema = json!({
"type": "object",
"required": ["action"],
"properties": {
"action": {
"type": "string",
"enum": [
"mail_list",
"mail_send",
"teams_message_list",
"teams_message_send",
"calendar_events_list",
"calendar_event_create",
"calendar_event_delete",
"onedrive_list",
"onedrive_download",
"sharepoint_search"
]
}
}
});
let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
assert_eq!(actions.len(), 10);
assert!(actions.contains(&json!("mail_list")));
assert!(actions.contains(&json!("sharepoint_search")));
}
#[test]
fn action_dispatch_table_is_exhaustive() {
let valid_actions = [
"mail_list",
"mail_send",
"teams_message_list",
"teams_message_send",
"calendar_events_list",
"calendar_event_create",
"calendar_event_delete",
"onedrive_list",
"onedrive_download",
"sharepoint_search",
];
assert_eq!(valid_actions.len(), 10);
assert!(!valid_actions.contains(&"invalid_action"));
}
}