Compare commits

...

6 Commits

Author SHA1 Message Date
simianastronaut
8e0f87fa3d fix(security): harden Microsoft 365 token cache and OAuth URL construction
- Restrict token cache file permissions to 0o600 on Unix to prevent
  other local users from reading cached OAuth access/refresh tokens
- Validate tenant_id format (alphanumeric, hyphens, dots) in config
  validation to prevent URL injection in OAuth endpoint construction
- Add custom Debug impl for CachedTokenState that redacts tokens
- Replace unstable DefaultHasher with deterministic fingerprint for
  cache filenames to prevent orphaned files on Rust toolchain updates
- Fix OneDrive path encoding to preserve '/' separators (encode each
  segment individually) so subdirectory traversal works correctly
- Check Content-Length header before buffering download response to
  prevent OOM on oversized files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 16:29:04 -04:00
Giulio V
97b9739320 fix(tools): address CodeRabbit review findings for Microsoft 365 integration 2026-03-15 15:40:02 +01:00
Giulio V
d600c775c2 fix(microsoft365): redact secrets, validate config, sanitize errors, encode paths
- Implement custom Debug for Microsoft365ResolvedConfig that redacts client_secret
- Fail tool registration when client_credentials flow has no client_secret
- Add config-load validation: client_credentials requires non-empty client_secret
- Wrap Graph API error responses to avoid leaking raw error bodies (log at debug)
- Stop propagating raw OAuth response bodies in auth error messages
- Replace verbatim device-code challenge logging with generic log + stderr output
- URL-encode all Graph path parameters (user_id, folder, team_id, channel_id, event_id, item_id) to prevent path traversal
- Cap OneDrive download max_size to MAX_ONEDRIVE_DOWNLOAD_SIZE regardless of caller input
2026-03-15 15:33:42 +01:00
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
Giulio V
71b16357f3 fix: resolve clippy and rustfmt violations in microsoft365 module
- Sort pub use imports alphabetically in tools/mod.rs
- Fix cast_lossless warnings by using u64::from/u32::try_from
- Fix cast_possible_truncation with safe try_from conversions
- Fix unused_async warning with allow attribute on sync_directory
- Fix unused_imports warning on TempDir (platform-conditional)
- Apply rustfmt to auth.rs method signatures and chain expressions
2026-03-15 15:33:42 +01:00
Giulio V
2dbe4e4812 feat(tools): add Microsoft 365 integration via Graph API
Adds a Microsoft365Tool that provides email, calendar, OneDrive, and
Teams operations through the Microsoft Graph API.

- OAuth2 PKCE flow with encrypted token caching
- Config-gated registration (microsoft365.enabled)
- Full parameter validation and security policy enforcement
- Comprehensive unit tests for auth, graph client, and tool execution
2026-03-15 15:31:20 +01:00
8 changed files with 1782 additions and 6 deletions

View File

@ -11,12 +11,12 @@ pub use schema::{
ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig,
GoogleTtsConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig,
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig,
McpServerConfig, McpTransport, MemoryConfig, ModelRouteConfig, MultimodalConfig,
NextcloudTalkConfig, NodesConfig, ObservabilityConfig, OpenAiTtsConfig, OtpConfig, OtpMethod,
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QdrantConfig,
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SkillsConfig,
SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig,
MultimodalConfig, NextcloudTalkConfig, NodesConfig, ObservabilityConfig, OpenAiTtsConfig,
OtpConfig, OtpMethod, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope,
QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig,
RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
StorageProviderSection, StreamMode, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode,
TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
};

View File

@ -188,6 +188,10 @@ pub struct Config {
#[serde(default)]
pub composio: ComposioConfig,
/// Microsoft 365 Graph API integration (`[microsoft365]`).
#[serde(default)]
pub microsoft365: Microsoft365Config,
/// Secrets encryption configuration (`[secrets]`).
#[serde(default)]
pub secrets: SecretsConfig,
@ -1282,6 +1286,78 @@ impl Default for ComposioConfig {
}
}
// ── Microsoft 365 (Graph API integration) ───────────────────────
/// Microsoft 365 integration via Microsoft Graph API (`[microsoft365]` section).
///
/// Provides access to Outlook mail, Teams messages, Calendar events,
/// OneDrive files, and SharePoint search.
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
pub struct Microsoft365Config {
/// Enable Microsoft 365 integration
#[serde(default, alias = "enable")]
pub enabled: bool,
/// Azure AD tenant ID
#[serde(default)]
pub tenant_id: Option<String>,
/// Azure AD application (client) ID
#[serde(default)]
pub client_id: Option<String>,
/// Azure AD client secret (stored encrypted when secrets.encrypt = true)
#[serde(default)]
pub client_secret: Option<String>,
/// Authentication flow: "client_credentials" or "device_code"
#[serde(default = "default_ms365_auth_flow")]
pub auth_flow: String,
/// OAuth scopes to request
#[serde(default = "default_ms365_scopes")]
pub scopes: Vec<String>,
/// Encrypt the token cache file on disk
#[serde(default = "default_true")]
pub token_cache_encrypted: bool,
/// User principal name or "me" (for delegated flows)
#[serde(default)]
pub user_id: Option<String>,
}
fn default_ms365_auth_flow() -> String {
"client_credentials".to_string()
}
fn default_ms365_scopes() -> Vec<String> {
vec!["https://graph.microsoft.com/.default".to_string()]
}
impl std::fmt::Debug for Microsoft365Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Microsoft365Config")
.field("enabled", &self.enabled)
.field("tenant_id", &self.tenant_id)
.field("client_id", &self.client_id)
.field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
.field("auth_flow", &self.auth_flow)
.field("scopes", &self.scopes)
.field("token_cache_encrypted", &self.token_cache_encrypted)
.field("user_id", &self.user_id)
.finish()
}
}
impl Default for Microsoft365Config {
fn default() -> Self {
Self {
enabled: false,
tenant_id: None,
client_id: None,
client_secret: None,
auth_flow: default_ms365_auth_flow(),
scopes: default_ms365_scopes(),
token_cache_encrypted: true,
user_id: None,
}
}
}
// ── Secrets (encrypted credential store) ────────────────────────
/// Secrets encryption configuration (`[secrets]` section).
@ -4186,6 +4262,7 @@ impl Default for Config {
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
microsoft365: Microsoft365Config::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
@ -4679,6 +4756,11 @@ impl Config {
&mut config.composio.api_key,
"config.composio.api_key",
)?;
decrypt_optional_secret(
&store,
&mut config.microsoft365.client_secret,
"config.microsoft365.client_secret",
)?;
decrypt_optional_secret(
&store,
@ -5227,6 +5309,63 @@ impl Config {
}
}
// Microsoft 365
if self.microsoft365.enabled {
let tenant = self
.microsoft365
.tenant_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if tenant.is_none() {
anyhow::bail!(
"microsoft365.tenant_id must not be empty when microsoft365 is enabled"
);
}
// Validate tenant_id format to prevent URL injection in OAuth
// endpoints. Azure AD tenant IDs are either UUIDs or verified
// domain names (alphanumeric, hyphens, dots).
if let Some(tid) = tenant {
if !tid
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '.')
{
anyhow::bail!(
"microsoft365.tenant_id contains invalid characters; \
expected UUID or domain format (alphanumeric, hyphens, dots)"
);
}
}
let client = self
.microsoft365
.client_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if client.is_none() {
anyhow::bail!(
"microsoft365.client_id must not be empty when microsoft365 is enabled"
);
}
let flow = self.microsoft365.auth_flow.trim();
if flow != "client_credentials" && flow != "device_code" {
anyhow::bail!(
"microsoft365.auth_flow must be 'client_credentials' or 'device_code'"
);
}
if flow == "client_credentials"
&& self
.microsoft365
.client_secret
.as_deref()
.map_or(true, |s| s.trim().is_empty())
{
anyhow::bail!(
"microsoft365.client_secret must not be empty when auth_flow is 'client_credentials'"
);
}
}
// MCP
if self.mcp.enabled {
validate_mcp_config(&self.mcp)?;
@ -5606,6 +5745,11 @@ impl Config {
&mut config_to_save.composio.api_key,
"config.composio.api_key",
)?;
encrypt_optional_secret(
&store,
&mut config_to_save.microsoft365.client_secret,
"config.microsoft365.client_secret",
)?;
encrypt_optional_secret(
&store,
@ -5950,6 +6094,7 @@ impl Config {
}
}
#[allow(clippy::unused_async)]
async fn sync_directory(path: &Path) -> Result<()> {
#[cfg(unix)]
{
@ -5975,6 +6120,7 @@ mod tests {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
#[allow(unused_imports)]
use tempfile::TempDir;
use tokio::sync::{Mutex, MutexGuard};
use tokio::test;
@ -6292,6 +6438,7 @@ default_temperature = 0.7
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
microsoft365: Microsoft365Config::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
@ -6583,6 +6730,7 @@ tool_dispatcher = "xml"
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
microsoft365: Microsoft365Config::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),

View File

@ -159,6 +159,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
tunnel: tunnel_config,
gateway: crate::config::GatewayConfig::default(),
composio: composio_config,
microsoft365: crate::config::Microsoft365Config::default(),
secrets: secrets_config,
browser: BrowserConfig::default(),
http_request: crate::config::HttpRequestConfig::default(),
@ -516,6 +517,7 @@ async fn run_quick_setup_with_home(
tunnel: crate::config::TunnelConfig::default(),
gateway: crate::config::GatewayConfig::default(),
composio: ComposioConfig::default(),
microsoft365: crate::config::Microsoft365Config::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: crate::config::HttpRequestConfig::default(),

View File

@ -0,0 +1,431 @@
use anyhow::Context;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
use tokio::sync::Mutex;
/// Cached OAuth2 token state persisted to disk between runs.
#[derive(Clone, Serialize, Deserialize)]
pub struct CachedTokenState {
pub access_token: String,
pub refresh_token: Option<String>,
/// Unix timestamp (seconds) when the access token expires.
pub expires_at: i64,
}
impl fmt::Debug for CachedTokenState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CachedTokenState")
.field("access_token", &"***")
.field("refresh_token", &self.refresh_token.as_ref().map(|_| "***"))
.field("expires_at", &self.expires_at)
.finish()
}
}
impl CachedTokenState {
/// Returns `true` when the token is expired or will expire within 60 seconds.
pub fn is_expired(&self) -> bool {
let now = chrono::Utc::now().timestamp();
self.expires_at <= now + 60
}
}
/// Thread-safe token cache with disk persistence.
pub struct TokenCache {
inner: RwLock<Option<CachedTokenState>>,
/// Serialises the slow acquire/refresh path so only one caller performs the
/// network round-trip while others wait and then read the updated cache.
acquire_lock: Mutex<()>,
config: super::types::Microsoft365ResolvedConfig,
cache_path: PathBuf,
}
impl TokenCache {
pub fn new(
config: super::types::Microsoft365ResolvedConfig,
zeroclaw_dir: &std::path::Path,
) -> anyhow::Result<Self> {
if config.token_cache_encrypted {
anyhow::bail!(
"microsoft365: token_cache_encrypted is enabled but encryption is not yet \
implemented; refusing to store tokens in plaintext. Set token_cache_encrypted \
to false or wait for encryption support."
);
}
// Scope cache file to (tenant_id, client_id, auth_flow) so config
// changes never reuse tokens from a different account/flow.
// Use a deterministic fingerprint (not DefaultHasher which is unstable
// across Rust versions and would orphan cache files on toolchain updates).
let fingerprint = format!(
"{}_{}_{}", config.tenant_id, config.client_id, config.auth_flow
).replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "_");
let cache_path = zeroclaw_dir.join(format!("ms365_token_cache_{fingerprint}.json"));
let cached = Self::load_from_disk(&cache_path);
Ok(Self {
inner: RwLock::new(cached),
acquire_lock: Mutex::new(()),
config,
cache_path,
})
}
/// Get a valid access token, refreshing or re-authenticating as needed.
pub async fn get_token(&self, client: &reqwest::Client) -> anyhow::Result<String> {
// Fast path: cached and not expired.
{
let guard = self.inner.read();
if let Some(ref state) = *guard {
if !state.is_expired() {
return Ok(state.access_token.clone());
}
}
}
// Slow path: serialise through a mutex so only one caller performs the
// network round-trip while concurrent callers wait and re-check.
let _lock = self.acquire_lock.lock().await;
// Re-check after acquiring the lock — another caller may have refreshed
// while we were waiting.
{
let guard = self.inner.read();
if let Some(ref state) = *guard {
if !state.is_expired() {
return Ok(state.access_token.clone());
}
}
}
let new_state = self.acquire_token(client).await?;
let token = new_state.access_token.clone();
self.persist_to_disk(&new_state);
*self.inner.write() = Some(new_state);
Ok(token)
}
async fn acquire_token(&self, client: &reqwest::Client) -> anyhow::Result<CachedTokenState> {
// Try refresh first if we have a refresh token and the flow supports it.
// Client credentials flow does not issue refresh tokens, so skip the
// attempt entirely to avoid a wasted round-trip.
if self.config.auth_flow.as_str() != "client_credentials" {
// Clone the token out so the RwLock guard is dropped before the await.
let refresh_token_copy = {
let guard = self.inner.read();
guard.as_ref().and_then(|state| state.refresh_token.clone())
};
if let Some(refresh_tok) = refresh_token_copy {
match self.refresh_token(client, &refresh_tok).await {
Ok(new_state) => return Ok(new_state),
Err(e) => {
tracing::debug!("ms365: refresh token failed, re-authenticating: {e}");
}
}
}
}
match self.config.auth_flow.as_str() {
"client_credentials" => self.client_credentials_flow(client).await,
"device_code" => self.device_code_flow(client).await,
other => anyhow::bail!("Unsupported auth flow: {other}"),
}
}
async fn client_credentials_flow(
&self,
client: &reqwest::Client,
) -> anyhow::Result<CachedTokenState> {
let client_secret = self
.config
.client_secret
.as_deref()
.context("client_credentials flow requires client_secret")?;
let token_url = format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
self.config.tenant_id
);
let scope = self.config.scopes.join(" ");
let resp = client
.post(&token_url)
.form(&[
("grant_type", "client_credentials"),
("client_id", &self.config.client_id),
("client_secret", client_secret),
("scope", &scope),
])
.send()
.await
.context("ms365: failed to request client_credentials token")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
tracing::debug!("ms365: client_credentials raw OAuth error: {body}");
anyhow::bail!("ms365: client_credentials token request failed ({status})");
}
let token_resp: TokenResponse = resp
.json()
.await
.context("ms365: failed to parse token response")?;
Ok(CachedTokenState {
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token,
expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in,
})
}
async fn device_code_flow(&self, client: &reqwest::Client) -> anyhow::Result<CachedTokenState> {
let device_code_url = format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
self.config.tenant_id
);
let scope = self.config.scopes.join(" ");
let resp = client
.post(&device_code_url)
.form(&[
("client_id", self.config.client_id.as_str()),
("scope", &scope),
])
.send()
.await
.context("ms365: failed to request device code")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
tracing::debug!("ms365: device_code initiation raw error: {body}");
anyhow::bail!("ms365: device code request failed ({status})");
}
let device_resp: DeviceCodeResponse = resp
.json()
.await
.context("ms365: failed to parse device code response")?;
// Log only a generic prompt; the full device_resp.message may contain
// sensitive verification URIs or codes that should not appear in logs.
tracing::info!(
"ms365: device code auth required — follow the instructions shown to the user"
);
// Print the user-facing message to stderr so the operator can act on it
// without it being captured in structured log sinks.
eprintln!("ms365: {}", device_resp.message);
let token_url = format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
self.config.tenant_id
);
let interval = device_resp.interval.max(5);
let max_polls = u32::try_from(
(device_resp.expires_in / i64::try_from(interval).unwrap_or(i64::MAX)).max(1),
)
.unwrap_or(u32::MAX);
for _ in 0..max_polls {
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
let poll_resp = client
.post(&token_url)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("client_id", self.config.client_id.as_str()),
("device_code", &device_resp.device_code),
])
.send()
.await
.context("ms365: failed to poll device code token")?;
if poll_resp.status().is_success() {
let token_resp: TokenResponse = poll_resp
.json()
.await
.context("ms365: failed to parse token response")?;
return Ok(CachedTokenState {
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token,
expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in,
});
}
let body = poll_resp.text().await.unwrap_or_default();
if body.contains("authorization_pending") {
continue;
}
if body.contains("slow_down") {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
tracing::debug!("ms365: device code polling raw error: {body}");
anyhow::bail!("ms365: device code polling failed");
}
anyhow::bail!("ms365: device code flow timed out waiting for user authorization")
}
async fn refresh_token(
&self,
client: &reqwest::Client,
refresh_token: &str,
) -> anyhow::Result<CachedTokenState> {
let token_url = format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
self.config.tenant_id
);
let mut params = vec![
("grant_type", "refresh_token"),
("client_id", self.config.client_id.as_str()),
("refresh_token", refresh_token),
];
let secret_ref;
if let Some(ref secret) = self.config.client_secret {
secret_ref = secret.as_str();
params.push(("client_secret", secret_ref));
}
let resp = client
.post(&token_url)
.form(&params)
.send()
.await
.context("ms365: failed to refresh token")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
tracing::debug!("ms365: token refresh raw error: {body}");
anyhow::bail!("ms365: token refresh failed ({status})");
}
let token_resp: TokenResponse = resp
.json()
.await
.context("ms365: failed to parse refresh token response")?;
Ok(CachedTokenState {
access_token: token_resp.access_token,
refresh_token: token_resp
.refresh_token
.or_else(|| Some(refresh_token.to_string())),
expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in,
})
}
fn load_from_disk(path: &std::path::Path) -> Option<CachedTokenState> {
let data = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&data).ok()
}
fn persist_to_disk(&self, state: &CachedTokenState) {
if let Ok(json) = serde_json::to_string_pretty(state) {
if let Err(e) = Self::write_restricted(&self.cache_path, &json) {
tracing::warn!("ms365: failed to persist token cache: {e}");
}
}
}
/// Write file with restricted permissions (0o600 on Unix) to prevent
/// other local users from reading cached OAuth tokens.
fn write_restricted(path: &std::path::Path, content: &str) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(content.as_bytes())?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, content)
}
}
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default = "default_expires_in")]
expires_in: i64,
}
fn default_expires_in() -> i64 {
3600
}
#[derive(Deserialize)]
struct DeviceCodeResponse {
device_code: String,
message: String,
#[serde(default = "default_device_interval")]
interval: u64,
#[serde(default = "default_device_expires_in")]
expires_in: i64,
}
fn default_device_interval() -> u64 {
5
}
fn default_device_expires_in() -> i64 {
900
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_is_expired_when_past_deadline() {
let state = CachedTokenState {
access_token: "test".into(),
refresh_token: None,
expires_at: chrono::Utc::now().timestamp() - 10,
};
assert!(state.is_expired());
}
#[test]
fn token_is_expired_within_buffer() {
let state = CachedTokenState {
access_token: "test".into(),
refresh_token: None,
expires_at: chrono::Utc::now().timestamp() + 30,
};
assert!(state.is_expired());
}
#[test]
fn token_is_valid_when_far_from_expiry() {
let state = CachedTokenState {
access_token: "test".into(),
refresh_token: None,
expires_at: chrono::Utc::now().timestamp() + 3600,
};
assert!(!state.is_expired());
}
#[test]
fn load_from_disk_returns_none_for_missing_file() {
let path = std::path::Path::new("/nonexistent/ms365_token_cache.json");
assert!(TokenCache::load_from_disk(path).is_none());
}
}

View File

@ -0,0 +1,516 @@
use anyhow::Context;
const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
/// Build the user path segment: `/me` or `/users/{user_id}`.
/// The user_id is percent-encoded to prevent path-traversal attacks.
fn user_path(user_id: &str) -> String {
if user_id == "me" {
"/me".to_string()
} else {
format!("/users/{}", urlencoding::encode(user_id))
}
}
/// Percent-encode a single path segment to prevent path-traversal attacks.
fn encode_path_segment(segment: &str) -> String {
urlencoding::encode(segment).into_owned()
}
/// List mail messages for a user.
pub async fn mail_list(
client: &reqwest::Client,
token: &str,
user_id: &str,
folder: Option<&str>,
top: u32,
) -> anyhow::Result<serde_json::Value> {
let base = user_path(user_id);
let path = match folder {
Some(f) => format!(
"{GRAPH_BASE}{base}/mailFolders/{}/messages",
encode_path_segment(f)
),
None => format!("{GRAPH_BASE}{base}/messages"),
};
let resp = client
.get(&path)
.bearer_auth(token)
.query(&[("$top", top.to_string())])
.send()
.await
.context("ms365: mail_list request failed")?;
handle_json_response(resp, "mail_list").await
}
/// Send a mail message.
pub async fn mail_send(
client: &reqwest::Client,
token: &str,
user_id: &str,
to: &[String],
subject: &str,
body: &str,
) -> anyhow::Result<()> {
let base = user_path(user_id);
let url = format!("{GRAPH_BASE}{base}/sendMail");
let to_recipients: Vec<serde_json::Value> = to
.iter()
.map(|addr| {
serde_json::json!({
"emailAddress": { "address": addr }
})
})
.collect();
let payload = serde_json::json!({
"message": {
"subject": subject,
"body": {
"contentType": "Text",
"content": body
},
"toRecipients": to_recipients
}
});
let resp = client
.post(&url)
.bearer_auth(token)
.json(&payload)
.send()
.await
.context("ms365: mail_send request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
tracing::debug!("ms365: mail_send raw error body: {body}");
anyhow::bail!("ms365: mail_send failed ({status}, code={code})");
}
Ok(())
}
/// List messages in a Teams channel.
pub async fn teams_message_list(
client: &reqwest::Client,
token: &str,
team_id: &str,
channel_id: &str,
top: u32,
) -> anyhow::Result<serde_json::Value> {
let url = format!(
"{GRAPH_BASE}/teams/{}/channels/{}/messages",
encode_path_segment(team_id),
encode_path_segment(channel_id)
);
let resp = client
.get(&url)
.bearer_auth(token)
.query(&[("$top", top.to_string())])
.send()
.await
.context("ms365: teams_message_list request failed")?;
handle_json_response(resp, "teams_message_list").await
}
/// Send a message to a Teams channel.
pub async fn teams_message_send(
client: &reqwest::Client,
token: &str,
team_id: &str,
channel_id: &str,
body: &str,
) -> anyhow::Result<()> {
let url = format!(
"{GRAPH_BASE}/teams/{}/channels/{}/messages",
encode_path_segment(team_id),
encode_path_segment(channel_id)
);
let payload = serde_json::json!({
"body": {
"content": body
}
});
let resp = client
.post(&url)
.bearer_auth(token)
.json(&payload)
.send()
.await
.context("ms365: teams_message_send request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
tracing::debug!("ms365: teams_message_send raw error body: {body}");
anyhow::bail!("ms365: teams_message_send failed ({status}, code={code})");
}
Ok(())
}
/// List calendar events in a date range.
pub async fn calendar_events_list(
client: &reqwest::Client,
token: &str,
user_id: &str,
start: &str,
end: &str,
top: u32,
) -> anyhow::Result<serde_json::Value> {
let base = user_path(user_id);
let url = format!("{GRAPH_BASE}{base}/calendarView");
let resp = client
.get(&url)
.bearer_auth(token)
.query(&[
("startDateTime", start.to_string()),
("endDateTime", end.to_string()),
("$top", top.to_string()),
])
.send()
.await
.context("ms365: calendar_events_list request failed")?;
handle_json_response(resp, "calendar_events_list").await
}
/// Create a calendar event.
pub async fn calendar_event_create(
client: &reqwest::Client,
token: &str,
user_id: &str,
subject: &str,
start: &str,
end: &str,
attendees: &[String],
body_text: Option<&str>,
) -> anyhow::Result<String> {
let base = user_path(user_id);
let url = format!("{GRAPH_BASE}{base}/events");
let attendee_list: Vec<serde_json::Value> = attendees
.iter()
.map(|email| {
serde_json::json!({
"emailAddress": { "address": email },
"type": "required"
})
})
.collect();
let mut payload = serde_json::json!({
"subject": subject,
"start": {
"dateTime": start,
"timeZone": "UTC"
},
"end": {
"dateTime": end,
"timeZone": "UTC"
},
"attendees": attendee_list
});
if let Some(text) = body_text {
payload["body"] = serde_json::json!({
"contentType": "Text",
"content": text
});
}
let resp = client
.post(&url)
.bearer_auth(token)
.json(&payload)
.send()
.await
.context("ms365: calendar_event_create request failed")?;
let value = handle_json_response(resp, "calendar_event_create").await?;
let event_id = value["id"].as_str().unwrap_or("unknown").to_string();
Ok(event_id)
}
/// Delete a calendar event by ID.
pub async fn calendar_event_delete(
client: &reqwest::Client,
token: &str,
user_id: &str,
event_id: &str,
) -> anyhow::Result<()> {
let base = user_path(user_id);
let url = format!(
"{GRAPH_BASE}{base}/events/{}",
encode_path_segment(event_id)
);
let resp = client
.delete(&url)
.bearer_auth(token)
.send()
.await
.context("ms365: calendar_event_delete request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
tracing::debug!("ms365: calendar_event_delete raw error body: {body}");
anyhow::bail!("ms365: calendar_event_delete failed ({status}, code={code})");
}
Ok(())
}
/// List children of a OneDrive folder.
pub async fn onedrive_list(
client: &reqwest::Client,
token: &str,
user_id: &str,
path: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
let base = user_path(user_id);
let url = match path {
Some(p) if !p.is_empty() => {
// Encode each path component individually, preserving '/' separators.
// Graph API expects literal '/' in the root:/{path}: syntax.
let encoded: String = p
.split('/')
.map(|seg| urlencoding::encode(seg).into_owned())
.collect::<Vec<_>>()
.join("/");
format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children")
}
_ => format!("{GRAPH_BASE}{base}/drive/root/children"),
};
let resp = client
.get(&url)
.bearer_auth(token)
.send()
.await
.context("ms365: onedrive_list request failed")?;
handle_json_response(resp, "onedrive_list").await
}
/// Download a OneDrive item by ID, with a maximum size guard.
pub async fn onedrive_download(
client: &reqwest::Client,
token: &str,
user_id: &str,
item_id: &str,
max_size: usize,
) -> anyhow::Result<Vec<u8>> {
let base = user_path(user_id);
let url = format!(
"{GRAPH_BASE}{base}/drive/items/{}/content",
encode_path_segment(item_id)
);
let resp = client
.get(&url)
.bearer_auth(token)
.send()
.await
.context("ms365: onedrive_download request failed")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
tracing::debug!("ms365: onedrive_download raw error body: {body}");
anyhow::bail!("ms365: onedrive_download failed ({status}, code={code})");
}
// Check Content-Length header first to reject oversized files before
// buffering the entire response body into memory.
if let Some(content_length) = resp.content_length() {
if usize::try_from(content_length).unwrap_or(usize::MAX) > max_size {
anyhow::bail!(
"ms365: file Content-Length exceeds max_size ({content_length} > {max_size})"
);
}
}
let bytes = resp
.bytes()
.await
.context("ms365: failed to read download body")?;
if bytes.len() > max_size {
anyhow::bail!(
"ms365: downloaded file exceeds max_size ({} > {max_size})",
bytes.len()
);
}
Ok(bytes.to_vec())
}
/// Search SharePoint for documents matching a query.
pub async fn sharepoint_search(
client: &reqwest::Client,
token: &str,
query: &str,
top: u32,
) -> anyhow::Result<serde_json::Value> {
let url = format!("{GRAPH_BASE}/search/query");
let payload = serde_json::json!({
"requests": [{
"entityTypes": ["driveItem", "listItem", "site"],
"query": {
"queryString": query
},
"from": 0,
"size": top
}]
});
let resp = client
.post(&url)
.bearer_auth(token)
.json(&payload)
.send()
.await
.context("ms365: sharepoint_search request failed")?;
handle_json_response(resp, "sharepoint_search").await
}
/// Extract a short, safe error code from a Graph API JSON error body.
/// Returns `None` when the body is not a recognised Graph error envelope.
fn extract_graph_error_code(body: &str) -> Option<String> {
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
let code = parsed
.get("error")
.and_then(|e| e.get("code"))
.and_then(|c| c.as_str())
.map(|s| s.to_string());
code
}
/// Parse a JSON response body, returning an error on non-success status.
/// Raw Graph API error bodies are not propagated; only the HTTP status and a
/// short error code (when available) are surfaced to avoid leaking internal
/// API details.
async fn handle_json_response(
resp: reqwest::Response,
operation: &str,
) -> anyhow::Result<serde_json::Value> {
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
tracing::debug!("ms365: {operation} raw error body: {body}");
anyhow::bail!("ms365: {operation} failed ({status}, code={code})");
}
resp.json()
.await
.with_context(|| format!("ms365: failed to parse {operation} response"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_path_me() {
assert_eq!(user_path("me"), "/me");
}
#[test]
fn user_path_specific_user() {
assert_eq!(user_path("user@contoso.com"), "/users/user%40contoso.com");
}
#[test]
fn mail_list_url_no_folder() {
let base = user_path("me");
let url = format!("{GRAPH_BASE}{base}/messages");
assert_eq!(url, "https://graph.microsoft.com/v1.0/me/messages");
}
#[test]
fn mail_list_url_with_folder() {
let base = user_path("me");
let folder = "inbox";
let url = format!(
"{GRAPH_BASE}{base}/mailFolders/{}/messages",
encode_path_segment(folder)
);
assert_eq!(
url,
"https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages"
);
}
#[test]
fn calendar_view_url() {
let base = user_path("user@example.com");
let url = format!("{GRAPH_BASE}{base}/calendarView");
assert_eq!(
url,
"https://graph.microsoft.com/v1.0/users/user%40example.com/calendarView"
);
}
#[test]
fn teams_message_url() {
let url = format!(
"{GRAPH_BASE}/teams/{}/channels/{}/messages",
encode_path_segment("team-123"),
encode_path_segment("channel-456")
);
assert_eq!(
url,
"https://graph.microsoft.com/v1.0/teams/team-123/channels/channel-456/messages"
);
}
#[test]
fn onedrive_root_url() {
let base = user_path("me");
let url = format!("{GRAPH_BASE}{base}/drive/root/children");
assert_eq!(
url,
"https://graph.microsoft.com/v1.0/me/drive/root/children"
);
}
#[test]
fn onedrive_path_url() {
let base = user_path("me");
let path = "Documents/Reports";
let encoded: String = path
.split('/')
.map(|seg| urlencoding::encode(seg).into_owned())
.collect::<Vec<_>>()
.join("/");
let url = format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children");
assert_eq!(
url,
"https://graph.microsoft.com/v1.0/me/drive/root:/Documents/Reports:/children"
);
}
#[test]
fn sharepoint_search_url() {
let url = format!("{GRAPH_BASE}/search/query");
assert_eq!(url, "https://graph.microsoft.com/v1.0/search/query");
}
}

View File

@ -0,0 +1,567 @@
//! 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)
.min(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"));
}
}

View File

@ -0,0 +1,55 @@
use serde::{Deserialize, Serialize};
/// Resolved Microsoft 365 configuration with all secrets decrypted and defaults applied.
#[derive(Clone, Serialize, Deserialize)]
pub struct Microsoft365ResolvedConfig {
pub tenant_id: String,
pub client_id: String,
pub client_secret: Option<String>,
pub auth_flow: String,
pub scopes: Vec<String>,
pub token_cache_encrypted: bool,
pub user_id: String,
}
impl std::fmt::Debug for Microsoft365ResolvedConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Microsoft365ResolvedConfig")
.field("tenant_id", &self.tenant_id)
.field("client_id", &self.client_id)
.field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
.field("auth_flow", &self.auth_flow)
.field("scopes", &self.scopes)
.field("token_cache_encrypted", &self.token_cache_encrypted)
.field("user_id", &self.user_id)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolved_config_serialization_roundtrip() {
let config = Microsoft365ResolvedConfig {
tenant_id: "test-tenant".into(),
client_id: "test-client".into(),
client_secret: Some("secret".into()),
auth_flow: "client_credentials".into(),
scopes: vec!["https://graph.microsoft.com/.default".into()],
token_cache_encrypted: false,
user_id: "me".into(),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: Microsoft365ResolvedConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.tenant_id, "test-tenant");
assert_eq!(parsed.client_id, "test-client");
assert_eq!(parsed.client_secret.as_deref(), Some("secret"));
assert_eq!(parsed.auth_flow, "client_credentials");
assert_eq!(parsed.scopes.len(), 1);
assert_eq!(parsed.user_id, "me");
}
}

View File

@ -48,6 +48,7 @@ pub mod mcp_transport;
pub mod memory_forget;
pub mod memory_recall;
pub mod memory_store;
pub mod microsoft365;
pub mod model_routing_config;
pub mod node_tool;
pub mod pdf_read;
@ -92,6 +93,7 @@ pub use mcp_tool::McpToolWrapper;
pub use memory_forget::MemoryForgetTool;
pub use memory_recall::MemoryRecallTool;
pub use memory_store::MemoryStoreTool;
pub use microsoft365::Microsoft365Tool;
pub use model_routing_config::ModelRoutingConfigTool;
#[allow(unused_imports)]
pub use node_tool::NodeTool;
@ -356,6 +358,61 @@ pub fn all_tools_with_runtime(
}
}
// Microsoft 365 Graph API integration
if root_config.microsoft365.enabled {
let ms_cfg = &root_config.microsoft365;
let tenant_id = ms_cfg
.tenant_id
.as_deref()
.unwrap_or_default()
.trim()
.to_string();
let client_id = ms_cfg
.client_id
.as_deref()
.unwrap_or_default()
.trim()
.to_string();
if !tenant_id.is_empty() && !client_id.is_empty() {
// Fail fast: client_credentials flow requires a client_secret at registration time.
if ms_cfg.auth_flow.trim() == "client_credentials"
&& ms_cfg
.client_secret
.as_deref()
.map_or(true, |s| s.trim().is_empty())
{
tracing::error!(
"microsoft365: client_credentials auth_flow requires a non-empty client_secret"
);
return (boxed_registry_from_arcs(tool_arcs), None);
}
let resolved = microsoft365::types::Microsoft365ResolvedConfig {
tenant_id,
client_id,
client_secret: ms_cfg.client_secret.clone(),
auth_flow: ms_cfg.auth_flow.clone(),
scopes: ms_cfg.scopes.clone(),
token_cache_encrypted: ms_cfg.token_cache_encrypted,
user_id: ms_cfg.user_id.as_deref().unwrap_or("me").to_string(),
};
// Store token cache in the config directory (next to config.toml),
// not the workspace directory, to keep bearer tokens out of the
// project tree.
let cache_dir = root_config.config_path.parent().unwrap_or(workspace_dir);
match Microsoft365Tool::new(resolved, security.clone(), cache_dir) {
Ok(tool) => tool_arcs.push(Arc::new(tool)),
Err(e) => {
tracing::error!("microsoft365: failed to initialize tool: {e}");
}
}
} else {
tracing::warn!(
"microsoft365: skipped registration because tenant_id or client_id is empty"
);
}
}
// Add delegation tool when agents are configured
let delegate_handle: Option<DelegateParentToolsHandle> = if agents.is_empty() {
None