diff --git a/CHANGELOG.md b/CHANGELOG.md index 075d544..e85247e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,12 @@ User-visible changes are recorded here. Alpha releases are for evaluation; stabl `status: left`, which `permesh check` counts as departed. The starter gains plan and verify workflows ([access plans](docs/access-plans.md), [ADR 0034](docs/adr/0034-access-plans.md)). +- Add experimental `permesh mcp`: a local, read-only MCP server (protocol + 2026-07-28) whose tools answer user, resource, admin, orphaned and profile + questions, prepare join, move and leave plan files inside the workspace, and + verify plans. Results carry the CLI's report, exit code and completeness. It never + changes access ([AI assistants](docs/mcp.md), + [ADR 0035](docs/adr/0035-read-only-mcp-server.md)). - Guided `provider add` now supports GitLab, Entra ID and AWS Identity Center. - `provider list` reports each external provider's ID and whether it has recorded diff --git a/Cargo.lock b/Cargo.lock index 2ae2d92..1791886 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "annotate-snippets" version = "0.12.16" @@ -377,6 +386,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "cipher" version = "0.5.2" @@ -590,6 +611,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "email-encoding" version = "0.4.2" @@ -1031,6 +1058,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -1143,6 +1194,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", + "serde", + "serde_core", ] [[package]] @@ -1520,6 +1573,12 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1545,6 +1604,7 @@ dependencies = [ "permesh-provider-sdk", "permesh-secrets", "reqwest", + "rmcp", "rpassword", "serde", "serde_json", @@ -1833,6 +1893,26 @@ dependencies = [ "rand_core", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "regex" version = "1.13.1" @@ -1914,6 +1994,28 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece64e19631fc38758181e637c15509a2c2709ee53bfde1e1d58f9dc991f919e" +dependencies = [ + "chrono", + "futures", + "indexmap", + "libc", + "pastey", + "pin-project-lite", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "rpassword" version = "7.5.4" @@ -2069,6 +2171,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2183,6 +2311,17 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -2475,6 +2614,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2658,6 +2811,7 @@ version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", diff --git a/README.md b/README.md index 89e9ef0..22a2119 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,17 @@ reports what is gone, what remains and what can't be checked. Permesh never trea "not seen" as "removed", and it makes no changes itself. See the [departure workflow](docs/offboarding.md). +## Ask an AI assistant + +`permesh mcp` lets an AI assistant on your machine ask the same questions, +read-only, with the same completeness warnings: + +```bash +claude mcp add permesh -- permesh mcp --config "$PWD/permesh.yaml" +``` + +It can prepare plan files but never changes access. See [AI assistants](docs/mcp.md). + ## Connect your systems In a new directory, separate from the demo: diff --git a/crates/permesh-cli/Cargo.toml b/crates/permesh-cli/Cargo.toml index 9649cf5..3b33d44 100644 --- a/crates/permesh-cli/Cargo.toml +++ b/crates/permesh-cli/Cargo.toml @@ -36,6 +36,7 @@ rpassword.workspace = true zeroize = { version = "1", features = ["serde"] } tempfile.workspace = true +rmcp = { version = "3.7", default-features = false, features = ["server", "transport-io"] } [dev-dependencies] diff --git a/crates/permesh-cli/src/args.rs b/crates/permesh-cli/src/args.rs index 655974a..311ac81 100644 --- a/crates/permesh-cli/src/args.rs +++ b/crates/permesh-cli/src/args.rs @@ -130,6 +130,8 @@ pub enum Command { #[arg(value_enum, conflicts_with = "json")] shell: clap_complete::Shell, }, + /// Serve read-only answers to a local AI assistant over MCP (stdio). + Mcp, /// Show version and privacy defaults. Version, } diff --git a/crates/permesh-cli/src/main.rs b/crates/permesh-cli/src/main.rs index 0b2453d..268926f 100644 --- a/crates/permesh-cli/src/main.rs +++ b/crates/permesh-cli/src/main.rs @@ -23,6 +23,7 @@ mod guided_add; mod guided_prompt; mod identity_command; mod inventory; +mod mcp; mod offboard; mod orphaned_output; mod output; @@ -95,6 +96,16 @@ async fn main() -> ExitCode { } let schema_version = report::command_schema_version(&cli.command); let blocking = blocking::BlockingPool::new(); + if let args::Command::Mcp = &cli.command { + // stdout belongs to the MCP session; errors only reach stderr. + return match mcp::serve(&cli, &blocking).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => match output::write_error(&error, false, schema_version) { + Ok(()) => ExitCode::from(error.code), + Err(_) => ExitCode::from(5), + }, + }; + } let result = if let args::Command::Provider { command: args::ProviderCommand::External { command }, } = &cli.command diff --git a/crates/permesh-cli/src/mcp/mod.rs b/crates/permesh-cli/src/mcp/mod.rs new file mode 100644 index 0000000..34b928a --- /dev/null +++ b/crates/permesh-cli/src/mcp/mod.rs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +//! Read-only MCP server over stdio (ADR 0035). Tools run existing commands only. +mod paths; +mod server; +mod tools; +use crate::{ + args::Cli, blocking::BlockingPool, cancellation::Cancellation, error::AppError, workspace, +}; +use rmcp::{ServiceExt, service::ServerInitializeError}; +use std::sync::Arc; + +/// Resolves on Ctrl+C, or on SIGTERM where it exists (how MCP clients stop servers). +async fn stopped() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + if let Ok(mut terminate) = signal(SignalKind::terminate()) { + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate.recv() => {}, + } + return; + } + } + let _ = tokio::signal::ctrl_c().await; +} + +pub(crate) async fn serve(cli: &Cli, blocking: &BlockingPool) -> Result<(), AppError> { + let workspace = std::fs::canonicalize(workspace::path(cli)?) + .map_err(|_| AppError::input("Cannot locate workspace configuration"))?; + permesh_config::Config::load(&workspace)?; + let lock = Arc::new(tokio::sync::Mutex::new(())); + let shutdown = Cancellation::new(); + let handler = server::Server::new(workspace, blocking.clone(), lock.clone(), shutdown.clone()); + let session = tokio::select! { + session = handler.serve(rmcp::transport::stdio()) => session, + () = stopped() => return Err(AppError::new(130, "Cancelled")), + }; + let running = match session { + Ok(running) => running, + Err(ServerInitializeError::ConnectionClosed(_)) => return Ok(()), + Err(_) => { + return Err(AppError::input( + "The MCP session could not start. permesh mcp supports MCP protocol 2026-07-28 only; update the assistant or its MCP client.", + )); + } + }; + let ended = tokio::select! { + result = running.waiting() => result + .map(|_| ()) + .map_err(|_| AppError::new(5, "The MCP session ended unexpectedly")), + () = stopped() => Err(AppError::new(130, "Cancelled")), + }; + // A running call drains like Ctrl+C in the CLI: providers are stopped and reaped first. + shutdown.cancel(); + let _drained = lock.lock().await; + ended +} diff --git a/crates/permesh-cli/src/mcp/paths.rs b/crates/permesh-cli/src/mcp/paths.rs new file mode 100644 index 0000000..ff77b38 --- /dev/null +++ b/crates/permesh-cli/src/mcp/paths.rs @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +//! Tool file arguments resolve inside the workspace root, never outside it. +use crate::error::AppError; +use std::path::{Component, Path, PathBuf}; + +const RELATIVE: &str = "Paths must be relative to the workspace"; +const OUTSIDE: &str = "Path is outside the workspace"; +const MISSING: &str = "File not found in the workspace"; +const EXISTS: &str = "Output file already exists"; +const PLACEMENT: &str = + "Plan files must be .json files outside people/, profiles/ and hidden folders"; + +/// Plan JSON is valid YAML: inside people/ or profiles/ it would break every profile load. +fn plan_placement(path: &Path) -> Result<(), AppError> { + let mut names = path.components().filter_map(|component| match component { + Component::Normal(name) => Some(name.to_string_lossy()), + _ => None, + }); + let first = names.next(); + let reserved = matches!(first.as_deref(), Some("people" | "profiles")); + let hidden = first + .into_iter() + .chain(names) + .any(|name| name.starts_with('.')); + let json = path + .extension() + .is_some_and(|extension| extension == "json"); + if reserved || hidden || !json { + Err(AppError::input(PLACEMENT)) + } else { + Ok(()) + } +} + +/// Refuses absolute paths and `..` that climbs above the root before touching the file system. +fn lexical(relative: &str) -> Result<&Path, AppError> { + let path = Path::new(relative); + if relative.is_empty() { + return Err(AppError::input(RELATIVE)); + } + let mut depth = 0usize; + for component in path.components() { + match component { + Component::Normal(_) => depth += 1, + Component::CurDir => {} + Component::ParentDir => { + depth = depth + .checked_sub(1) + .ok_or_else(|| AppError::input(OUTSIDE))?; + } + Component::RootDir | Component::Prefix(_) => return Err(AppError::input(RELATIVE)), + } + } + Ok(path) +} +fn canonical_root(root: &Path) -> Result { + std::fs::canonicalize(root).map_err(|_| AppError::input(MISSING)) +} + +/// An input file inside the root; symlinks are followed and must stay inside. +pub(crate) fn existing(root: &Path, relative: &str) -> Result { + let path = lexical(relative)?; + let root = canonical_root(root)?; + let resolved = std::fs::canonicalize(root.join(path)).map_err(|_| AppError::input(MISSING))?; + if resolved.starts_with(&root) { + Ok(resolved) + } else { + Err(AppError::input(OUTSIDE)) + } +} + +/// A plan file to create inside the root: a `.json` file outside workspace inputs and hidden +/// folders, whose parent exists inside the root and which does not exist yet. +pub(crate) fn new_file(root: &Path, relative: &str) -> Result { + let path = lexical(relative)?; + let root = canonical_root(root)?; + let joined = root.join(path); + // A dangling symlink counts as existing, so nothing is written through it. + if std::fs::symlink_metadata(&joined).is_ok() { + return Err(AppError::input(EXISTS)); + } + plan_placement(path)?; + let (Some(parent), Some(name)) = (joined.parent(), joined.file_name()) else { + return Err(AppError::input(EXISTS)); + }; + let parent = std::fs::canonicalize(parent).map_err(|_| AppError::input(MISSING))?; + if parent.starts_with(&root) { + Ok(parent.join(name)) + } else { + Err(AppError::input(OUTSIDE)) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use std::fs; + + fn message(result: Result) -> String { + let error = result.unwrap_err(); + assert_eq!(error.code, 2); + error.message + } + + #[test] + fn accepts_relative_file_inside_root() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("snapshots")).unwrap(); + fs::write(dir.path().join("snapshots/a.json"), "{}").unwrap(); + let root = fs::canonicalize(dir.path()).unwrap(); + assert_eq!( + existing(dir.path(), "snapshots/a.json").unwrap(), + root.join("snapshots/a.json") + ); + } + + #[test] + fn rejects_escaping_and_absolute_paths() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("a")).unwrap(); + for (path, expected) in [ + ("../x", "Path is outside the workspace"), + ("a/../../x", "Path is outside the workspace"), + ("/etc/passwd", "Paths must be relative to the workspace"), + ("", "Paths must be relative to the workspace"), + ("missing.json", "File not found in the workspace"), + ] { + let text = message(existing(dir.path(), path)); + assert_eq!(text, expected, "{path}"); + assert!(path.is_empty() || !text.contains(path), "{path}"); + } + } + + #[cfg(unix)] + #[test] + fn rejects_symlink_out_of_root() { + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("file"), "secret").unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink(outside.path().join("file"), dir.path().join("link")).unwrap(); + std::os::unix::fs::symlink(outside.path(), dir.path().join("out")).unwrap(); + assert_eq!( + message(existing(dir.path(), "link")), + "Path is outside the workspace" + ); + assert_eq!( + message(new_file(dir.path(), "out/new.json")), + "Path is outside the workspace" + ); + } + + #[test] + fn new_file_refuses_workspace_inputs_and_non_json() { + let dir = tempfile::tempdir().unwrap(); + for folder in ["people", "profiles", ".git", "plans", "plans/.hidden"] { + fs::create_dir_all(dir.path().join(folder)).unwrap(); + } + for path in [ + "people/carol.yaml", + "people/carol.json", + "profiles/x.json", + ".git/x.json", + "plans/.hidden/x.json", + "plans/x.yaml", + "plans/x", + "./people/x.json", + ] { + assert_eq!( + message(new_file(dir.path(), path)), + "Plan files must be .json files outside people/, profiles/ and hidden folders", + "{path}" + ); + } + assert!(new_file(dir.path(), "plans/x.json").is_ok()); + } + + #[test] + fn new_file_requires_existing_parent_inside_root_and_absent_target() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("plans")).unwrap(); + let root = fs::canonicalize(dir.path()).unwrap(); + assert_eq!( + new_file(dir.path(), "plans/new.json").unwrap(), + root.join("plans/new.json") + ); + fs::write(dir.path().join("plans/old.json"), "{}").unwrap(); + assert_eq!( + message(new_file(dir.path(), "plans/old.json")), + "Output file already exists" + ); + assert_eq!( + message(new_file(dir.path(), "../new.json")), + "Path is outside the workspace" + ); + assert_eq!( + message(new_file(dir.path(), "nodir/new.json")), + "File not found in the workspace" + ); + assert_eq!( + message(new_file(dir.path(), "plans/..")), + "Output file already exists" + ); + } +} diff --git a/crates/permesh-cli/src/mcp/server.rs b/crates/permesh-cli/src/mcp/server.rs new file mode 100644 index 0000000..3990cdc --- /dev/null +++ b/crates/permesh-cli/src/mcp/server.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +use super::tools; +use crate::{ + app, args::Cli, blocking::BlockingPool, cancellation::Cancellation, error::AppError, + report::Outcome, +}; +use clap::Parser; +use rmcp::{ + ErrorData, RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResponse, Implementation, ListToolsResult, + PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerConfig, + }, + service::RequestContext, +}; +use std::{borrow::Cow, path::PathBuf, sync::Arc}; + +pub(crate) const INSTRUCTIONS: &str = "Permesh answers who has access to what, read-only. Every tool result has `complete`: when it is false, some providers could not be fully read and missing access is not proof of absence, so say you cannot tell. Plan tools write a new plan file for a person to review and carry out; Permesh never changes access."; +const VERSIONS: &[ProtocolVersion] = &[ProtocolVersion::V_2026_07_28]; + +pub(crate) struct Server { + workspace: PathBuf, + blocking: BlockingPool, + /// One command at a time: discoveries never share provider sessions or the keyring. + /// The session takes it after shutdown to wait for the running call's drain. + lock: Arc>, + /// Cancelled when the session ends (stdin closed, Ctrl+C or SIGTERM). + shutdown: Cancellation, +} +impl Server { + pub(crate) fn new( + workspace: PathBuf, + blocking: BlockingPool, + lock: Arc>, + shutdown: Cancellation, + ) -> Self { + Self { + workspace, + blocking, + lock, + shutdown, + } + } + async fn execute( + &self, + tool: &tools::Tool, + stop: impl std::future::Future, + ) -> Result { + let cli = Cli::try_parse_from(tool.argv(&self.workspace)?) + .map_err(|_| AppError::input("Invalid tool arguments"))?; + if self.shutdown.is_cancelled() { + return Err(AppError::new(130, "Cancelled")); + } + let cancellation = Cancellation::new(); + let operation = app::run(&cli, &self.blocking, &cancellation); + tokio::pin!(operation); + tokio::select! { + biased; + () = async { + tokio::select! { + () = stop => {}, + () = self.shutdown.cancelled() => {}, + } + } => { + // Drain like Ctrl+C in main: providers are terminated and reaped first. + cancellation.cancel(); + match operation.await { + Err(error) if error.code == 5 => Err(error), + _ => Err(AppError::new(130, "Cancelled")), + } + } + result = &mut operation => result, + } + } +} +impl ServerHandler for Server { + fn get_info(&self) -> ServerConfig { + let mut info = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); + info.server_info = Implementation::new("permesh", env!("CARGO_PKG_VERSION")); + info.instructions = Some(INSTRUCTIONS.into()); + info + } + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(VERSIONS) + } + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(tools::definitions())) + } + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let arguments = request.arguments.unwrap_or_default(); + let outcome = match tools::parse(&request.name, arguments) { + Ok(tool) => { + let _turn = self.lock.lock().await; + self.execute(&tool, context.ct.cancelled()).await + } + Err(message) => Err(AppError::input(message)), + }; + Ok(CallToolResponse::Complete(tools::result(outcome))) + } +} diff --git a/crates/permesh-cli/src/mcp/tools.rs b/crates/permesh-cli/src/mcp/tools.rs new file mode 100644 index 0000000..3637776 --- /dev/null +++ b/crates/permesh-cli/src/mcp/tools.rs @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: MIT +//! The closed set of MCP tools. Each maps to exactly one existing command. +use super::paths; +use crate::{error::AppError, report::Outcome}; +use rmcp::model::{CallToolResult, ContentBlock, Tool as Definition, ToolAnnotations}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use std::{ + ffi::{OsStr, OsString}, + path::Path, + sync::Arc, +}; + +pub(crate) const NOTE: &str = + "Some providers could not be fully read. Missing access here is not proof of absence."; + +#[derive(Debug, Deserialize)] +#[serde( + tag = "name", + content = "arguments", + rename_all = "snake_case", + deny_unknown_fields +)] +pub(crate) enum Tool { + Doctor(DoctorArgs), + #[serde(rename = "user_access")] + User(UserArgs), + #[serde(rename = "resource_access")] + Resource(ResourceArgs), + Admins(NoArgs), + Orphaned(NoArgs), + ProfileCheck(ProfileCheckArgs), + PlanJoin(JoinArgs), + PlanMove(MoveArgs), + PlanLeave(LeaveArgs), + VerifyPlan(VerifyArgs), +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DoctorArgs { + #[serde(default)] + details: bool, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct UserArgs { + identity: String, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResourceArgs { + query: Option, + instance: Option, + id: Option, + snapshot: Option, +} +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct NoArgs {} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProfileCheckArgs { + snapshot: Option, + policy: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct JoinArgs { + person: String, + profiles: Vec, + output: String, + snapshot: Option, + expires_in_hours: Option, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct MoveArgs { + person: String, + remove: Vec, + #[serde(default)] + add: Vec, + output: String, + snapshot: Option, + expires_in_hours: Option, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LeaveArgs { + person: String, + annotations: Option, + output: String, + snapshot: Option, + expires_in_hours: Option, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct VerifyArgs { + plan: String, + snapshot: Option, +} + +fn schema(value: Value) -> Arc> { + match value { + Value::Object(map) => Arc::new(map), + _ => Arc::new(Map::new()), + } +} +fn query(name: &'static str, description: &'static str, input: Value) -> Definition { + Definition::new(name, description, schema(input)) + .annotate(ToolAnnotations::new().read_only(true).open_world(true)) +} + +fn object(properties: Value, required: &[&str]) -> Value { + json!({"type": "object", "properties": properties, "required": required, "additionalProperties": false}) +} +/// Writes one new local file and never overwrites; no provider is changed. +fn plan(name: &'static str, description: &'static str, input: Value) -> Definition { + Definition::new(name, description, schema(input)).annotate( + ToolAnnotations::new() + .read_only(false) + .destructive(false) + .idempotent(false) + .open_world(true), + ) +} +fn plan_properties(mut properties: Value) -> Value { + properties["person"] = + json!({"type": "string", "description": "The person's identity, as in their people file."}); + properties["output"] = json!({"type": "string", "description": "New .json plan file to write, relative to the workspace and outside people/, profiles/ and hidden folders; its folder must exist and the file must not."}); + properties["snapshot"] = json!({"type": "string", "description": SNAPSHOT}); + properties["expires_in_hours"] = json!({"type": "integer", "minimum": 1, "maximum": 168, "default": 24, "description": "Hours the plan stays fresh, from the evidence start."}); + properties +} +fn names(description: &str) -> Value { + json!({"type": "array", "items": {"type": "string"}, "description": description}) +} +const SNAPSHOT: &str = + "Saved snapshot to read offline instead of fresh discovery; a path relative to the workspace."; + +pub(crate) fn definitions() -> Vec { + vec![ + query( + "doctor", + "Check the workspace configuration, credentials and provider connectivity.", + object( + json!({"details": {"type": "boolean", "description": "Include curated stage codes and remediation."}}), + &[], + ), + ), + query( + "user_access", + "Show a person's observed access across providers and the paths (groups, teams, roles) that explain it, each with its certainty.", + object( + json!({"identity": {"type": "string", "description": "Identity such as an email address, or a provider account."}}), + &["identity"], + ), + ), + query( + "resource_access", + "Show who has observed access to one resource and through which paths. Give query (the exact display label), or instance and id.", + object( + json!({ + "query": {"type": "string", "description": "Exact resource display label; ambiguity returns candidates."}, + "instance": {"type": "string", "description": "Provider instance ID, with id."}, + "id": {"type": "string", "description": "Stable resource ID in that instance."}, + "snapshot": {"type": "string", "description": SNAPSHOT} + }), + &[], + ), + ), + query( + "admins", + "Show privileged access, uncertain roles and unresolved group grants.", + object(json!({}), &[]), + ), + query( + "orphaned", + "Review accounts not confidently tied to an active identity in the authoritative directory, including inactive and departed people.", + object(json!({}), &[]), + ), + query( + "profile_check", + "Compare people's declared access profiles with observed and derived access: missing access, and unexplained access nobody's profile justifies.", + object( + json!({ + "snapshot": {"type": "string", "description": SNAPSHOT}, + "policy": {"type": "string", "description": "Policy file whose unexplained_access exceptions apply; a path relative to the workspace."} + }), + &[], + ), + ), + plan( + "plan_join", + "Prepare a reviewable plan file of the access a person needs for added profiles, including accounts still to create. Permesh never applies it.", + object( + plan_properties(json!({"profiles": names("Profiles to add.")})), + &["person", "profiles", "output"], + ), + ), + plan( + "plan_move", + "Prepare a reviewable plan file for a profile change: removals only for access no remaining profile justifies, and shared access listed as kept. Permesh never applies it.", + object( + plan_properties( + json!({"remove": names("Profiles to take away."), "add": names("Profiles to add.")}), + ), + &["person", "remove", "output"], + ), + ), + plan( + "plan_leave", + "Prepare a reviewable plan file removing all of a departing person's access, separating expected from unexplained access, with account, ownership and machine-account review steps. Permesh never applies it.", + object( + plan_properties( + json!({"annotations": {"type": "string", "description": "Owner annotations for machine accounts, as in offboard; a path relative to the workspace."}}), + ), + &["person", "output"], + ), + ), + query( + "verify_plan", + "Check each step of a plan file against fresh discovery: done, not done or cannot verify, plus any extra access.", + object( + json!({ + "plan": {"type": "string", "description": "Plan file, relative to the workspace."}, + "snapshot": {"type": "string", "description": SNAPSHOT} + }), + &["plan"], + ), + ), + ] +} + +/// Only listed tools are callable, even when a variant exists for a later tool. +pub(crate) fn parse(name: &str, arguments: Map) -> Result { + if !definitions().iter().any(|tool| tool.name == name) { + return Err("Unknown tool; call tools/list for the available tools".into()); + } + serde_json::from_value(json!({"name": name, "arguments": arguments})) + .map_err(|_| "Invalid tool arguments; check the tool's input schema".into()) +} + +fn option(name: &str, value: impl AsRef) -> OsString { + let mut flag = OsString::from(format!("--{name}=")); + flag.push(value); + flag +} + +impl Tool { + /// Exact argv: values are `--name=value` and positionals follow `--`, so data never becomes a flag. + pub(crate) fn argv(&self, workspace: &Path) -> Result, AppError> { + let root = workspace.parent().unwrap_or(Path::new(".")); + let file = |name: &str, value: &Option| -> Result, AppError> { + value + .as_deref() + .map(|value| Ok(option(name, paths::existing(root, value)?))) + .transpose() + }; + // Flags every plan tool shares; `output` is a new file inside the workspace. + let common = |output: &str, + snapshot: &Option, + hours: Option| + -> Result, AppError> { + let mut flags = vec![option("output", paths::new_file(root, output)?)]; + flags.extend(file("snapshot", snapshot)?); + flags.extend(hours.map(|hours| option("expires-in-hours", hours.to_string()))); + Ok(flags) + }; + let mut argv: Vec = vec!["permesh".into(), "--json".into()]; + argv.push(option("config", workspace)); + match self { + Tool::Doctor(args) => { + argv.push("doctor".into()); + if args.details { + argv.push("--details".into()); + } + } + Tool::User(args) => { + argv.extend(["user".into(), "--".into(), (&args.identity).into()]); + } + Tool::Resource(args) => { + argv.push("resource".into()); + argv.extend(file("snapshot", &args.snapshot)?); + match (&args.query, &args.instance, &args.id) { + (Some(query), None, None) => argv.extend(["--".into(), query.into()]), + (None, Some(instance), Some(id)) => { + argv.extend([option("instance", instance), option("id", id)]); + } + _ => return Err(AppError::input("Give query, or instance and id")), + } + } + Tool::Admins(_) => argv.push("admins".into()), + Tool::Orphaned(_) => argv.push("orphaned".into()), + Tool::ProfileCheck(args) => { + argv.extend(["profile".into(), "check".into()]); + argv.extend(file("snapshot", &args.snapshot)?); + argv.extend(file("policy", &args.policy)?); + } + Tool::PlanJoin(args) => { + argv.extend(["access".into(), "join".into()]); + argv.extend(args.profiles.iter().map(|p| option("profile", p))); + argv.extend(common(&args.output, &args.snapshot, args.expires_in_hours)?); + argv.extend(["--".into(), (&args.person).into()]); + } + Tool::PlanMove(args) => { + argv.extend(["access".into(), "move".into()]); + argv.extend(args.remove.iter().map(|p| option("remove", p))); + argv.extend(args.add.iter().map(|p| option("add", p))); + argv.extend(common(&args.output, &args.snapshot, args.expires_in_hours)?); + argv.extend(["--".into(), (&args.person).into()]); + } + Tool::PlanLeave(args) => { + argv.extend(["access".into(), "leave".into()]); + argv.extend(file("annotations", &args.annotations)?); + argv.extend(common(&args.output, &args.snapshot, args.expires_in_hours)?); + argv.extend(["--".into(), (&args.person).into()]); + } + Tool::VerifyPlan(args) => { + argv.extend(["access".into(), "verify".into()]); + argv.extend(file("snapshot", &args.snapshot)?); + let plan = paths::existing(root, &args.plan)?; + argv.extend(["--".into(), plan.into()]); + } + } + Ok(argv) + } +} + +pub(crate) fn result(outcome: Result) -> CallToolResult { + let (value, failed) = match outcome { + Ok(outcome) => { + let complete = outcome.report.complete; + let mut value = json!({"exit_code": outcome.code, "report": outcome.report}); + if !complete { + value["note"] = json!(NOTE); + } + (value, false) + } + Err(error) => ( + json!({"exit_code": error.code, "error": {"message": error.message}}), + true, + ), + }; + let content = vec![ContentBlock::text(value.to_string())]; + let mut result = if failed { + CallToolResult::error(content) + } else { + CallToolResult::success(content) + }; + result.structured_content = Some(value); + result +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use crate::{ + access::AccessCommand, + args::{Cli, Command}, + }; + use clap::Parser; + + fn parsed(root: &Path, tool: &Tool) -> Cli { + Cli::try_parse_from(tool.argv(&root.join("permesh.yaml")).unwrap()).unwrap() + } + /// One sample per variant; file arguments exist inside `root`. + fn samples(root: &Path) -> Vec { + std::fs::write(root.join("p.json"), "{}").unwrap(); + let snapshot = || Some("p.json".to_string()); + vec![ + Tool::PlanJoin(JoinArgs { + person: "-p".into(), + profiles: vec!["--x".into()], + output: "new.json".into(), + snapshot: snapshot(), + expires_in_hours: Some(48), + }), + Tool::PlanMove(MoveArgs { + person: "-p".into(), + remove: vec!["a".into()], + add: vec!["b".into()], + output: "new.json".into(), + snapshot: None, + expires_in_hours: None, + }), + Tool::PlanLeave(LeaveArgs { + person: "-p".into(), + annotations: snapshot(), + output: "new.json".into(), + snapshot: None, + expires_in_hours: None, + }), + Tool::VerifyPlan(VerifyArgs { + plan: "p.json".into(), + snapshot: snapshot(), + }), + Tool::Doctor(DoctorArgs { details: true }), + Tool::User(UserArgs { + identity: "a@example.com".into(), + }), + Tool::Resource(ResourceArgs { + query: Some("-x".into()), + instance: None, + id: None, + snapshot: None, + }), + Tool::Admins(NoArgs {}), + Tool::Orphaned(NoArgs {}), + Tool::ProfileCheck(ProfileCheckArgs { + snapshot: None, + policy: None, + }), + ] + } + + #[test] + fn flag_like_values_stay_positional() { + let tool = Tool::User(UserArgs { + identity: "--config=/x".into(), + }); + let argv = tool.argv(Path::new("/w/permesh.yaml")).unwrap(); + assert_eq!(argv[argv.len() - 3..], ["user", "--", "--config=/x"]); + let dir = tempfile::tempdir().unwrap(); + let cli = parsed(dir.path(), &tool); + assert!(matches!(&cli.command, Command::User { identity } if identity == "--config=/x")); + assert_eq!(cli.config, Some(dir.path().join("permesh.yaml"))); + assert!(cli.json); + } + + #[test] + fn argv_only_reaches_allowed_commands() { + let dir = tempfile::tempdir().unwrap(); + for tool in samples(dir.path()) { + let cli = parsed(dir.path(), &tool); + assert!( + matches!( + cli.command, + Command::Doctor { .. } + | Command::User { .. } + | Command::Resource(_) + | Command::Admins + | Command::Orphaned + | Command::Profile { + command: crate::profile_command::ProfileCommand::Check { .. } + } + | Command::Access { + command: AccessCommand::Join { .. } + | AccessCommand::Move { .. } + | AccessCommand::Leave { .. } + | AccessCommand::Verify { .. } + } + ), + "{tool:?}" + ); + } + } +} diff --git a/crates/permesh-cli/tests/mcp.rs b/crates/permesh-cli/tests/mcp.rs new file mode 100644 index 0000000..5db90e7 --- /dev/null +++ b/crates/permesh-cli/tests/mcp.rs @@ -0,0 +1,796 @@ +// SPDX-License-Identifier: MIT +#![allow(clippy::unwrap_used, clippy::expect_used)] +use serde_json::{Value, json}; +use std::{ + io::{BufRead, BufReader, Write}, + path::Path, + process::{Child, ChildStdin, Command, Output, Stdio}, + sync::mpsc::{Receiver, channel}, + time::Duration, +}; + +const TOOLS: &[&str] = &[ + "doctor", + "user_access", + "resource_access", + "admins", + "orphaned", + "profile_check", + "plan_join", + "plan_move", + "plan_leave", + "verify_plan", +]; +const VERSION: &str = "2026-07-28"; +const INSTRUCTIONS: &str = "Permesh answers who has access to what, read-only. Every tool result has `complete`: when it is false, some providers could not be fully read and missing access is not proof of absence, so say you cannot tell. Plan tools write a new plan file for a person to review and carry out; Permesh never changes access."; + +fn command(dir: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_permesh")); + command + .current_dir(dir) + .env("NO_COLOR", "1") + .env("PERMESH_DATA_DIR", dir.join("state")); + command +} +fn run(dir: &Path, args: &[&str]) -> Output { + command(dir).args(args).output().unwrap() +} +fn demo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + assert!(run(dir.path(), &["init", "--demo"]).status.success()); + dir +} +fn meta() -> Value { + json!({ + "io.modelcontextprotocol/protocolVersion": VERSION, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"} + }) +} + +/// A newline-delimited JSON-RPC client for `permesh mcp`; every stdout line is kept. +struct Client { + child: Child, + stdin: Option, + lines: Receiver, + seen: Vec, + next: u64, +} +impl Client { + fn start(dir: &Path) -> Self { + let mut child = command(dir) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let stdin = child.stdin.take(); + let stdout = child.stdout.take().unwrap(); + let (tx, lines) = channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + Self { + child, + stdin, + lines, + seen: vec![], + next: 1, + } + } + fn send(&mut self, message: &Value) { + let stdin = self.stdin.as_mut().unwrap(); + writeln!(stdin, "{message}").unwrap(); + stdin.flush().unwrap(); + } + /// Sends a request with modern `_meta` merged into params; returns its id. + fn request_raw(&mut self, method: &str, params: Value) -> u64 { + let id = self.next; + self.next += 1; + self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + id + } + fn start_request(&mut self, method: &str, mut params: Value) -> u64 { + params["_meta"] = meta(); + self.request_raw(method, params) + } + fn response(&mut self, id: u64) -> Value { + loop { + let line = self + .lines + .recv_timeout(Duration::from_secs(60)) + .expect("no response from permesh mcp"); + self.seen.push(line.clone()); + let value: Value = serde_json::from_str(&line).unwrap(); + if value["id"] == json!(id) { + return value; + } + } + } + fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.start_request(method, params); + self.response(id) + } + fn call(&mut self, name: &str, arguments: Value) -> Value { + let response = self.request("tools/call", json!({"name": name, "arguments": arguments})); + assert!(response["error"].is_null(), "{response}"); + response["result"].clone() + } + fn tool_names(&mut self) -> Vec { + let response = self.request("tools/list", json!({})); + response["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap().to_string()) + .collect() + } + /// Closes stdin and returns the exit code and every stdout line. + fn finish(mut self) -> (Option, Vec) { + drop(self.stdin.take()); + let status = self.child.wait().unwrap(); + while let Ok(line) = self.lines.recv_timeout(Duration::from_secs(5)) { + self.seen.push(line); + } + (status.code(), self.seen) + } +} + +#[test] +fn server_discover_describes_server() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let result = client.request("server/discover", json!({}))["result"].clone(); + assert_eq!(result["supportedVersions"], json!([VERSION]), "{result}"); + assert_eq!( + result["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], + "permesh" + ); + assert_eq!(result["instructions"], INSTRUCTIONS); + assert!(result["capabilities"]["tools"].is_object(), "{result}"); +} + +#[test] +fn tools_list_without_handshake() { + let dir = demo(); + let mut client = Client::start(dir.path()); + assert_eq!(client.tool_names(), TOOLS); +} + +#[test] +fn legacy_initialize_is_rejected() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let id = client.request_raw( + "initialize", + json!({"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "old", "version": "0"}}), + ); + let response = client.response(id); + assert_eq!(response["error"]["code"], -32022, "{response}"); + assert_eq!(response["error"]["data"]["supported"], json!([VERSION])); + let (code, lines) = client.finish(); + assert_eq!(code, Some(2)); + assert!( + lines + .iter() + .all(|l| serde_json::from_str::(l).is_ok()) + ); +} + +#[test] +fn missing_meta_is_invalid_params() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let id = client.request_raw("tools/list", json!({})); + assert_eq!(client.response(id)["error"]["code"], -32602); + assert_eq!(client.tool_names(), TOOLS); +} + +#[test] +fn doctor_tool_returns_report() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let result = client.call("doctor", json!({})); + assert_eq!(result["isError"], false, "{result}"); + let structured = &result["structuredContent"]; + assert_eq!(structured["exit_code"], 0, "{result}"); + assert_eq!(structured["report"]["command"], "doctor"); + let text: Value = serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(&text, structured); +} + +#[test] +fn unknown_tool_and_argument_are_errors() { + let dir = demo(); + let mut client = Client::start(dir.path()); + for (name, arguments) in [("nope", json!({})), ("doctor", json!({"detail": true}))] { + let response = client.request("tools/call", json!({"name": name, "arguments": arguments})); + let tool_error = response["result"]["isError"] == true; + let rpc_error = response["error"]["code"] == -32602; + assert!(tool_error || rpc_error, "{response}"); + } + assert_eq!(client.tool_names(), TOOLS); +} + +#[test] +fn stdout_is_only_json_rpc() { + let dir = demo(); + let mut client = Client::start(dir.path()); + client.request("server/discover", json!({})); + client.tool_names(); + client.call("doctor", json!({"details": true})); + let (code, lines) = client.finish(); + assert_eq!(code, Some(0)); + assert!(!lines.is_empty()); + for line in lines { + let value: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(value["jsonrpc"], "2.0", "{line}"); + } +} + +#[test] +fn outside_workspace_exits_input() { + let dir = tempfile::tempdir().unwrap(); + let output = command(dir.path()) + .arg("mcp") + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); +} + +const BACKEND: &str = "profile: backend\ngrants:\n - {instance: demo, group: team-10}\n - {instance: demo, role: Read, resource: repo-21}\n"; + +fn json_run(dir: &Path, args: &[&str]) -> Value { + let output = command(dir).args(args).arg("--json").output().unwrap(); + serde_json::from_slice(&output.stdout).unwrap_or_else(|_| panic!("{args:?}: {output:?}")) +} +fn write(dir: &Path, name: &str, body: &str) { + let path = dir.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, body).unwrap(); +} +fn structured(result: &Value) -> &Value { + &result["structuredContent"] +} + +#[test] +fn user_access_matches_cli() { + let dir = demo(); + let cli = json_run(dir.path(), &["user", "alice@example.com"]); + let mut client = Client::start(dir.path()); + let result = client.call("user_access", json!({"identity": "alice@example.com"})); + assert_eq!(result["isError"], false, "{result}"); + let report = &structured(&result)["report"]; + assert_eq!(report["result"], cli["result"]); + assert_eq!(report["schema_version"], 2); + let access = report["result"]["access"].as_array().unwrap(); + assert!(!access.is_empty()); + assert!( + access.iter().all(|path| path["certainty"].is_string()), + "{report}" + ); +} + +#[test] +fn user_access_not_found_is_error_exit_1() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let result = client.call("user_access", json!({"identity": "missing@example.com"})); + assert_eq!(result["isError"], true, "{result}"); + assert_eq!(structured(&result)["exit_code"], 1); +} + +#[test] +fn resource_access_by_query_and_by_id() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let by_id = client.call( + "resource_access", + json!({"instance": "demo", "id": "repo-20"}), + ); + assert_eq!(by_id["isError"], false, "{by_id}"); + let resource = &structured(&by_id)["report"]["result"]["resource"]; + assert_eq!(resource["key"]["id"], "repo-20"); + let name = resource["name"].as_str().unwrap(); + let by_query = client.call("resource_access", json!({"query": name})); + assert_eq!( + structured(&by_query)["report"]["result"]["resource"]["key"]["id"], + "repo-20", + "{by_query}" + ); + let cli = json_run( + dir.path(), + &["resource", "--instance", "demo", "--id", "repo-20"], + ); + let timeless = |value: &Value| { + let mut value = value.clone(); + for key in ["observation_started_at", "observation_completed_at"] { + value.as_object_mut().unwrap().remove(key); + } + value + }; + assert!( + timeless(&structured(&by_id)["report"]["result"]) == timeless(&cli["result"]), + "resource result differs from the CLI" + ); +} + +#[test] +fn resource_access_rejects_both_forms() { + let dir = demo(); + let mut client = Client::start(dir.path()); + for arguments in [ + json!({"query": "x", "instance": "demo", "id": "repo-20"}), + json!({}), + json!({"instance": "demo"}), + ] { + let result = client.call("resource_access", arguments); + assert_eq!(result["isError"], true, "{result}"); + assert_eq!(structured(&result)["exit_code"], 2); + assert_eq!( + structured(&result)["error"]["message"], + "Give query, or instance and id" + ); + } +} + +#[test] +fn admins_and_orphaned_match_cli() { + let dir = demo(); + let mut client = Client::start(dir.path()); + for (tool, command) in [("admins", "admins"), ("orphaned", "orphaned")] { + let cli = json_run(dir.path(), &[command]); + let result = client.call(tool, json!({})); + if cli["error"].is_object() { + assert_eq!(result["isError"], true, "{result}"); + assert_eq!( + structured(&result)["error"]["message"], + cli["error"]["message"] + ); + assert_eq!(structured(&result)["exit_code"], cli["error"]["code"]); + } else { + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(structured(&result)["report"]["result"], cli["result"]); + } + } +} + +#[test] +fn profile_check_reports_unexplained() { + let dir = demo(); + write(dir.path(), "profiles/backend.yaml", BACKEND); + write( + dir.path(), + "people/bob.yaml", + "identity: bob@example.com\nprofiles: [backend]\n", + ); + let cli = json_run(dir.path(), &["profile", "check"]); + let mut client = Client::start(dir.path()); + let result = client.call("profile_check", json!({})); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(structured(&result)["report"]["result"], cli["result"]); + assert_eq!(structured(&result)["report"]["command"], "profile_check"); +} + +#[test] +fn incomplete_capture_carries_note() { + let dir = demo(); + write(dir.path(), "profiles/backend.yaml", BACKEND); + write( + dir.path(), + "people/bob.yaml", + "identity: bob@example.com\nprofiles: [backend]\n", + ); + assert!( + run(dir.path(), &["snapshot", "create", "--output", "s.json"]) + .status + .success() + ); + let mut snapshot: Value = + serde_json::from_slice(&std::fs::read(dir.path().join("s.json")).unwrap()).unwrap(); + let limitations = json!(["Fixture visibility incomplete"]); + snapshot["providers"][0]["state"] = "partial".into(); + snapshot["providers"][0]["limitations"] = limitations.clone(); + snapshot["providers"][0]["data"]["complete"] = false.into(); + snapshot["providers"][0]["data"]["limitations"] = limitations; + std::fs::write(dir.path().join("partial.json"), snapshot.to_string()).unwrap(); + let mut client = Client::start(dir.path()); + let result = client.call("profile_check", json!({"snapshot": "partial.json"})); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(structured(&result)["exit_code"], 4); + assert_eq!(structured(&result)["report"]["complete"], false); + assert_eq!( + structured(&result)["note"], + "Some providers could not be fully read. Missing access here is not proof of absence." + ); +} + +#[test] +fn snapshot_outside_workspace_is_input_error() { + let dir = demo(); + let mut client = Client::start(dir.path()); + let result = client.call("profile_check", json!({"snapshot": "../s.json"})); + assert_eq!(result["isError"], true, "{result}"); + assert_eq!(structured(&result)["exit_code"], 2); + assert_eq!( + structured(&result)["error"]["message"], + "Path is outside the workspace" + ); +} + +fn step_ids(plan: &Value) -> std::collections::BTreeSet { + plan["steps"] + .as_array() + .unwrap() + .iter() + .map(|step| step["id"].as_str().unwrap().to_owned()) + .collect() +} +fn planned() -> tempfile::TempDir { + let dir = demo(); + write(dir.path(), "profiles/backend.yaml", BACKEND); + write( + dir.path(), + "people/bob.yaml", + "identity: bob@example.com\nprofiles: [backend]\n", + ); + std::fs::create_dir(dir.path().join("plans")).unwrap(); + dir +} + +#[test] +fn plan_join_writes_plan_file() { + let dir = planned(); + let mut client = Client::start(dir.path()); + let result = client.call( + "plan_join", + json!({"person": "carol@example.com", "profiles": ["backend"], "output": "plans/carol.json"}), + ); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(structured(&result)["exit_code"], 0); + let report = &structured(&result)["report"]; + assert_eq!(report["command"], "access_plan"); + let file: Value = + serde_json::from_slice(&std::fs::read(dir.path().join("plans/carol.json")).unwrap()) + .unwrap(); + assert_eq!(file, report["result"]["access_plan"]); + assert_eq!(file["kind"], "join"); + let cli = command(dir.path()) + .args(["access", "verify", "plans/carol.json", "--json"]) + .output() + .unwrap(); + assert_ne!(cli.status.code(), Some(2), "{cli:?}"); +} + +#[test] +fn plan_tool_never_overwrites() { + let dir = planned(); + let mut client = Client::start(dir.path()); + let arguments = json!({"person": "carol@example.com", "profiles": ["backend"], "output": "plans/carol.json"}); + client.call("plan_join", arguments.clone()); + let before = std::fs::read(dir.path().join("plans/carol.json")).unwrap(); + let result = client.call("plan_join", arguments); + assert_eq!(result["isError"], true, "{result}"); + assert_eq!(structured(&result)["exit_code"], 2); + assert_eq!( + structured(&result)["error"]["message"], + "Output file already exists" + ); + assert_eq!( + std::fs::read(dir.path().join("plans/carol.json")).unwrap(), + before + ); +} + +#[test] +fn plan_move_and_leave_match_cli() { + let dir = planned(); + let mut client = Client::start(dir.path()); + let moved = client.call( + "plan_move", + json!({"person": "alice@example.com", "remove": ["backend"], "output": "plans/move.json"}), + ); + assert_eq!(moved["isError"], false, "{moved}"); + let left = client.call( + "plan_leave", + json!({"person": "alice@example.com", "output": "plans/leave.json", "expires_in_hours": 48}), + ); + assert_eq!(left["isError"], false, "{left}"); + let cli_move = json_run( + dir.path(), + &[ + "access", + "move", + "alice@example.com", + "--remove", + "backend", + "--output", + "cli-move.json", + ], + ); + let cli_leave = json_run( + dir.path(), + &[ + "access", + "leave", + "alice@example.com", + "--output", + "cli-leave.json", + ], + ); + let ids = |value: &Value| step_ids(&value["result"]["access_plan"]); + assert!(!ids(&cli_move).is_empty()); + assert_eq!(ids(&structured(&moved)["report"]), ids(&cli_move)); + assert_eq!(ids(&structured(&left)["report"]), ids(&cli_leave)); + assert_eq!( + structured(&left)["report"]["result"]["access_plan"]["kind"], + "leave" + ); +} + +#[test] +fn verify_plan_reports_steps() { + let dir = planned(); + let mut client = Client::start(dir.path()); + client.call( + "plan_join", + json!({"person": "bob@example.com", "profiles": ["backend"], "output": "plans/bob.json"}), + ); + let result = client.call("verify_plan", json!({"plan": "plans/bob.json"})); + let cli = command(dir.path()) + .args(["access", "verify", "plans/bob.json", "--json"]) + .output() + .unwrap(); + let cli_report: Value = serde_json::from_slice(&cli.stdout).unwrap(); + assert_eq!(result["isError"], false, "{result}"); + assert_eq!( + structured(&result)["exit_code"], + json!(cli.status.code().unwrap()) + ); + assert_eq!(structured(&result)["exit_code"], 6); + assert_eq!( + structured(&result)["report"]["result"]["checks"], + cli_report["result"]["checks"] + ); +} + +#[test] +fn plan_output_outside_workspace_is_refused() { + let dir = planned(); + let mut client = Client::start(dir.path()); + let result = client.call( + "plan_join", + json!({"person": "carol@example.com", "profiles": ["backend"], "output": "../x.json"}), + ); + assert_eq!(result["isError"], true, "{result}"); + assert_eq!(structured(&result)["exit_code"], 2); + assert_eq!( + structured(&result)["error"]["message"], + "Path is outside the workspace" + ); + assert!(!dir.path().parent().unwrap().join("x.json").exists()); +} + +/// A workspace whose only provider hangs in discovery after writing `ready.pid`. +#[cfg(unix)] +fn slow_workspace() -> (tempfile::TempDir, std::path::PathBuf) { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + let digest = fixture(&root); + let config = json!({"version":1,"organization":{"name":"Cancellation fixture"}, + "providers":[{"id":"instance","type":"external","external":{"provider":"fixture","sha256":digest,"configuration":{},"credentials":{}}}]}); + std::fs::write( + root.join("permesh.yaml"), + serde_json::to_vec(&config).unwrap(), + ) + .unwrap(); + let review = json_run(&root, &["provider", "external", "review", "instance"]); + let fingerprint = review["result"]["fingerprint"].as_str().unwrap(); + json_run( + &root, + &[ + "provider", + "external", + "approve", + "instance", + "--fingerprint", + fingerprint, + "--accept-risk", + ], + ); + (temporary, root) +} +#[cfg(unix)] +fn wait_for_provider(root: &Path) -> String { + let marker = root.join("ready.pid"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !marker.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + std::fs::read_to_string(&marker) + .expect("discovery never started") + .trim() + .to_owned() +} +#[cfg(unix)] +fn alive(pid: &str) -> bool { + Command::new("kill") + .args(["-0", pid]) + .output() + .unwrap() + .status + .success() +} + +#[cfg(unix)] +#[test] +fn sigterm_during_call_stops_providers() { + let (_temporary, root) = slow_workspace(); + let mut client = Client::start(&root); + client.start_request( + "tools/call", + json!({"name": "user_access", "arguments": {"identity": "missing"}}), + ); + let provider = wait_for_provider(&root); + let server = client.child.id().to_string(); + assert!( + Command::new("kill") + .args(["-TERM", &server]) + .status() + .unwrap() + .success() + ); + let (code, lines) = client.finish(); + let survived = alive(&provider); + if survived { + let _ = Command::new("kill").args(["-KILL", &provider]).status(); + } + assert!(!survived, "provider outlived the stopped MCP server"); + assert_eq!(code, Some(130)); + for line in lines { + assert!(serde_json::from_str::(&line).is_ok(), "{line}"); + } +} + +#[cfg(unix)] +#[test] +fn stdin_eof_during_call_stops_providers() { + let (_temporary, root) = slow_workspace(); + let mut client = Client::start(&root); + client.start_request( + "tools/call", + json!({"name": "user_access", "arguments": {"identity": "missing"}}), + ); + let provider = wait_for_provider(&root); + let (_, _) = client.finish(); + let survived = alive(&provider); + if survived { + let _ = Command::new("kill").args(["-KILL", &provider]).status(); + } + assert!(!survived, "provider outlived the closed MCP session"); +} + +#[cfg(unix)] +#[test] +fn cancelled_call_drains_and_server_continues() { + use std::time::Instant; + let (_temporary, root) = slow_workspace(); + let mut client = Client::start(&root); + let id = client.start_request( + "tools/call", + json!({"name": "user_access", "arguments": {"identity": "missing"}}), + ); + let marker = root.join("ready.pid"); + let deadline = Instant::now() + Duration::from_secs(10); + while !marker.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + let provider_pid = std::fs::read_to_string(&marker).expect("discovery never started"); + client.send( + &json!({"jsonrpc": "2.0", "method": "notifications/cancelled", + "params": {"requestId": id, "reason": "test", "_meta": meta()}}), + ); + let deadline = Instant::now() + Duration::from_secs(10); + let alive = || { + Command::new("kill") + .args(["-0", provider_pid.trim()]) + .output() + .unwrap() + .status + .success() + }; + while alive() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + let still_alive = alive(); + if still_alive { + let _ = Command::new("kill") + .args(["-KILL", provider_pid.trim()]) + .status(); + } + assert!( + !still_alive, + "cancelled provider was not terminated and reaped" + ); + assert_eq!(client.tool_names().len(), TOOLS.len()); + let (_, lines) = client.finish(); + for line in lines { + let value: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(value["jsonrpc"], "2.0", "{line}"); + assert!(!line.contains("CANCELLATION_PRIVATE"), "{line}"); + } +} + +/// A real native provider speaks either legacy protocol, exposes a private +/// observation, then hangs after marking its PID. This synchronizes SIGINT with +/// active discovery rather than relying on startup timing. +#[cfg(unix)] +fn fixture(root: &Path) -> String { + let source = root.join("peer.rs"); + let binary = root.join("peer"); + let code = r##" +use std::io::{BufRead, Write}; +fn main() { + let mut lines = std::io::stdin().lock().lines(); + let handshake = lines.next().unwrap().unwrap(); + let version = if handshake.contains("\"protocol\":2") { 2 } else { 1 }; + println!("{}", r#"{"protocol":VERSION,"id":"handshake","event":"handshake","provider":"fixture","capabilities":["accounts"],"draft":true}"#.replace("VERSION", &version.to_string())); + std::io::stdout().flush().unwrap(); + let discover = lines.next().unwrap().unwrap(); + assert!(discover.contains("\"method\":\"discover\"")); + println!("{}", r#"{"protocol":VERSION,"id":"discover","event":"record","kind":"account","data":{"key":{"provider":"instance","id":"guest"},"login":"CANCELLATION_PRIVATE_OBSERVATION","kind":"human","verified_emails":[]}}"#.replace("VERSION", &version.to_string())); + std::io::stdout().flush().unwrap(); + eprintln!("CANCELLATION_PRIVATE_STDERR"); + let marker = std::path::Path::new(MARKER); + let temporary = marker.with_extension("tmp"); + std::fs::write(&temporary, std::process::id().to_string()).unwrap(); + std::fs::rename(temporary, marker).unwrap(); + // Deliberately ignore cancellation; the host must terminate and reap us. + loop { std::thread::sleep(std::time::Duration::from_secs(60)); } +} +"## + .replace("MARKER", &format!("{:?}", root.join("ready.pid"))); + std::fs::write(&source, code).unwrap(); + assert!( + Command::new("rustc") + .arg(&source) + .arg("-o") + .arg(&binary) + .status() + .unwrap() + .success() + ); + let inspect = json_run( + root, + &["provider", "external", "inspect", binary.to_str().unwrap()], + ); + let digest = inspect["result"]["inspection"]["sha256"] + .as_str() + .unwrap() + .to_owned(); + json_run( + root, + &[ + "provider", + "external", + "trust", + binary.to_str().unwrap(), + "--id", + "fixture", + "--sha256", + &digest, + "--capability", + "accounts", + "--accept-risk", + ], + ); + digest +} diff --git a/docs/README.md b/docs/README.md index 8debd5c..d96db64 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,7 @@ contracts; use its [release notes](releases/0.1.0-alpha.2.md) and the | Compare saved observations offline | [Snapshots and diffs](snapshots.md) | | Declare expected access and find unexplained access | [Access profiles](profiles.md) | | Plan and verify a join, project move or departure | [Joiner, mover and leaver plans](access-plans.md) | +| Ask an AI assistant the same questions (MCP) | [AI assistants](mcp.md) | | Run weekly checks in CI and get notified | [Scheduled checks](check.md), [notifications](notifications.md), [starter](starter.md) | | Assess a departure and verify later observations | [Read-only offboarding](offboarding.md) | | Review one resource or run local rules | [Resource queries and policies](resource-policies.md) | diff --git a/docs/adr/0035-read-only-mcp-server.md b/docs/adr/0035-read-only-mcp-server.md new file mode 100644 index 0000000..d25c339 --- /dev/null +++ b/docs/adr/0035-read-only-mcp-server.md @@ -0,0 +1,55 @@ +# ADR 0035: Read-only MCP server + +Status: accepted for the unreleased source candidate (experimental) + +## Decision + +`permesh mcp` runs a local [Model Context Protocol](https://modelcontextprotocol.io) +server over stdio, so an AI assistant on the administrator's machine can ask the +same questions as the CLI. It never changes access, including after a later apply +stage ([ADR 0028](0028-separate-opt-in-remediation.md)): applying stays a deliberate +CLI action. + +- **Same commands, same trust.** Each tool is one variant of a closed list and + becomes an exact `permesh --json` argument vector, which the normal parser and + command code run. The server therefore uses the same workspace, approvals and + credentials as the CLI. There is no generic pass-through, so no tool can reach + `check` (it sends notifications), `snapshot save`, `identity map`, `auth`, + `provider`, `init`, `offboard` or `access review`. +- **Tools.** `doctor`, `user_access`, `resource_access`, `admins`, `orphaned`, + `profile_check`, `plan_join`, `plan_move`, `plan_leave` and `verify_plan`. +- **One write.** The plan tools write a new `.json` plan file, which a person + reviews and carries out. They never overwrite a file, and never write into + `people/`, `profiles/` or hidden folders, where plan JSON would be read as + workspace YAML. HTML views are not offered. +- **Paths stay inside the workspace.** File arguments are relative to the workspace + folder. Absolute paths, `..` that leaves it and symlinks that point outside are + input errors, checked before any command runs. Values are passed as `--name=value` + and positionals follow `--`, so a value never becomes a flag. +- **Results carry completeness.** A result holds the exit code and the same report + `--json` prints. When the capture is incomplete it adds a note that missing access + is not proof of absence. Exit codes 4 and 6 are answers, not tool errors. +- **One call at a time.** Calls run in sequence. A cancelled call cancels the + command and waits until provider processes are stopped, as Ctrl+C does. Ending the + session (stdin closed, Ctrl+C or SIGTERM) does the same for a running call. +- **Protocol 2026-07-28 only.** The server uses the official Rust SDK (`rmcp`) and + supports only the stateless MCP revision with `server/discover`. Clients that + start with the older `initialize` handshake get an error naming that version. + +## Why + +A single administrator often has no second person to ask. An assistant that can +answer "who can still reach this repository, and through which group?" helps only +if it reports uncertainty honestly and cannot be talked into changing anything. +Running the existing commands, instead of a second implementation, keeps the +answers identical to the CLI's and inherits every safety rule they already follow. +Supporting one protocol revision keeps the surface small while there are no users +to stay compatible with. + +## Consequences + +The assistant sees the same access data the CLI shows, so it is only as private as +the assistant and its client. Older MCP clients cannot connect until they support +2026-07-28. Each call runs fresh discovery unless a snapshot is given, so answers +can be slow and use provider rate limits. Plan files need an existing folder in the +workspace. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..d981e96 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,102 @@ +# AI assistants (MCP) + +`permesh mcp` lets an AI assistant on your machine ask Permesh the questions you +would ask the CLI: who has access to a repository, why someone still has it, what a +move or departure should remove. The assistant gets the same answers as the CLI, +including whether they are complete. It cannot change access. + +The server and its tools are experimental ([stability](stability.md)). + +## Set it up + +The assistant starts `permesh mcp` itself and talks to it over stdin and stdout. +Point it at your workspace file with an absolute path. + +Claude Code: + +```bash +claude mcp add permesh -- permesh mcp --config /path/to/company-access/permesh.yaml +``` + +Other clients take an entry like this in their MCP server configuration: + +```json +{ + "mcpServers": { + "permesh": { + "command": "permesh", + "args": ["mcp", "--config", "/path/to/company-access/permesh.yaml"] + } + } +} +``` + +Set up providers, credentials and external-provider approvals with the CLI first. +The server uses them as they are; it never approves, stores credentials or prompts. + +**Client requirement.** The server speaks MCP protocol version 2026-07-28 only. +A client that starts with the older `initialize` handshake gets error `-32022` +listing `2026-07-28`, and `permesh mcp` exits with code 2. Update the client if +you see that error. + +## Tools + +| Tool | Answers | Runs | +| --- | --- | --- | +| `doctor` | Is the configuration valid and can each provider be reached? | `permesh doctor` | +| `user_access` | What can this person reach, and through which groups and roles? | `permesh user` | +| `resource_access` | Who can reach this resource? Give `query`, or `instance` and `id`. | `permesh resource` | +| `admins` | Who has privileged access, and where is privilege uncertain? | `permesh admins` | +| `orphaned` | Which accounts don't belong to an active person? | `permesh orphaned` | +| `profile_check` | What access is missing, and what does no profile explain? | `permesh profile check` | +| `plan_join` | Write a plan for the access a new profile needs. | `permesh access join` | +| `plan_move` | Write a plan for a profile change. | `permesh access move` | +| `plan_leave` | Write a plan to remove a departing person's access. | `permesh access leave` | +| `verify_plan` | Which plan steps are done, not done, or can't be checked? | `permesh access verify` | + +`resource_access`, `profile_check`, the plan tools and `verify_plan` accept +`snapshot` to read a saved snapshot instead of running fresh discovery. See +[access plans](access-plans.md) for what plans contain. + +Not available through MCP, on purpose: `check` (it sends notifications), +`snapshot save`, `identity map`, `auth`, `provider`, `init`, `offboard` and +`access review`. Use the CLI for those. + +## Reading results + +Each result's structured content is: + +- `exit_code`: the CLI exit code ([exit codes](output-schema.md)). 4 (incomplete) + and 6 (needs action) are answers, not errors. +- `report`: the same report `--json` prints, with `complete`, provider states and, + on every access path, its certainty. +- `note`, when `report.complete` is false: "Some providers could not be fully + read. Missing access here is not proof of absence." + +A failure sets `isError` and gives `error.message` instead of `report`. The same +JSON is also in the text content. + +Ending the session (closing stdin, Ctrl+C or SIGTERM) during a call stops that +call the same way: provider processes are stopped before `permesh mcp` exits. + +When `complete` is false, a good assistant says it can't tell, rather than "no +access". The server's instructions tell it so. + +## Files and paths + +File arguments (`snapshot`, `policy`, `annotations`, `plan`, `output`) are paths +relative to the folder that holds `permesh.yaml`. Absolute paths, `..` that leaves +the folder and symlinks that point outside it are refused. + +The plan tools are the only tools that write. They create `output` as a new +`.json` file; its folder must already exist (for example `plans/`) and the file +must not. Plans can't be written into `people/`, `profiles/` or hidden folders, +where they would be read as workspace files. Permesh +never overwrites a plan and never carries one out. + +## Privacy + +The assistant sees what the CLI shows: names, accounts, groups and resources. Use +an assistant and client you trust with that data. Each call may run fresh +discovery against your providers, which takes time and uses their rate limits; +give `snapshot` to work from a saved capture instead. diff --git a/docs/stability.md b/docs/stability.md index 69d045a..69c5186 100644 --- a/docs/stability.md +++ b/docs/stability.md @@ -41,6 +41,8 @@ These may change between releases without a migration guide. (`join`, `move`, `leave`, `verify`, `review`), the `permesh_access_plan` v1 file, people files' `status`, and the plan and verify starter workflows. A second stability declaration will freeze these formats. +- [AI assistants](mcp.md): `permesh mcp`, its tool names, arguments and result + shape, and the supported MCP protocol version. ## Provider maturity