arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! The MCP capability model (master Reservation #4).
//!
//! Capabilities are explicit, per-capability opt-in. The shipped read-only
//! UAG-reading tools require no capabilities. The model exists so the
//! capability-gating boundary is real, tested production code now — the
//! deferred destructive tools (applying migrations through a future gated
//! tool) will require [`Capability::DestructiveWrite`], and a client
//! requesting [`Capability::Shell`] is always refused.
//!
//! # Safe defaults
//!
//! * [`Capability::ReadOnlyDb`] — the read-only database capability — is
//!   granted by default. (No shipped tool uses it yet; the deferred
//!   `models`/`database_schema` tools from AP2.1-6 will read through it.)
//! * [`Capability::DestructiveWrite`] is **off by default** and on only
//!   when `arc mcp --allow-destructive-writes` runs.
//! * [`Capability::Shell`] — arbitrary command execution — is **never
//!   granted**. There is no CLI flag for it. A tool that requires `Shell`
//!   always gets [`crate::commands::mcp::error::McpError::CapabilityRefused`].
//!   This is the "no arbitrary command execution" invariant: the capability
//!   exists in the model only so the refusal is uniform and typed, not so it
//!   can be turned on.

use crate::cli::McpOptions;

/// A discrete MCP capability. Tools declare the set they require; the
/// server grants a set at startup; a tool runs only if the server grants
/// every capability it requires.
///
/// The variants are `#[allow(dead_code)]` because **no shipped tool requires a
/// capability yet** — every read-only tool's `required_capabilities` is `&[]`.
/// The variants are *constructed* only in the capability negative tests and in
/// the `required_capabilities` slice a future gated tool (the deferred
/// destructive migration tool) will carry. Constructing a variant in
/// production now would be a fake implementation (AGENTS.md §7); the model is
/// real, tested production code (the gate `check` runs on every `tools/call`)
/// whose prod construction is genuinely deferred, not faked.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
#[allow(dead_code)] // variants constructed by the deferred gated tool + tests; see above.
pub(crate) enum Capability {
    /// Read-only database access (the default). Read queries, schema
    /// introspection — never a write. Granted by default.
    ReadOnlyDb,
    /// Destructive writes (applying migrations, destructive DB lifecycle).
    /// Off by default; on only with `--allow-destructive-writes`.
    DestructiveWrite,
    /// Arbitrary shell / command execution. **Never granted.** Present in
    /// the model so a request for it is refused with a typed error through
    /// the same gate as any ungranted capability — there is no special
    /// "shell" code path in the tools.
    Shell,
}

impl Capability {
    /// The stable capability name surfaced to clients in a `CapabilityRefused`
    /// error. Stable across refactors; never the Rust identifier.
    pub(crate) const fn as_str(self) -> &'static str {
        match self {
            Self::ReadOnlyDb => "ReadOnlyDb",
            Self::DestructiveWrite => "DestructiveWrite",
            Self::Shell => "Shell",
        }
    }
}

/// The set of capabilities the server grants for a session. Built from the
/// CLI flags; never mutated after construction (no hidden request-scoped
/// capability escalation — AGENTS.md §20).
#[derive(Debug, Clone, Default)]
pub(crate) struct CapabilitySet {
    read_only_db: bool,
    destructive_write: bool,
    // `Shell` is never stored here; it is never grantable.
}

impl CapabilitySet {
    /// Build the granted set from the CLI options.
    ///
    /// `ReadOnlyDb` is granted by default. `DestructiveWrite` is granted
    /// only when `--allow-destructive-writes` is passed. `Shell` is never
    /// granted — there is no flag for it.
    pub(crate) fn from_options(options: &McpOptions) -> Self {
        Self {
            read_only_db: true,
            destructive_write: options.allow_destructive_writes,
        }
    }

    /// Build a granted set for tests, asserting the invariants: `ReadOnlyDb`
    /// may be on or off, `DestructiveWrite` may be on or off, `Shell` is
    /// always refused.
    #[cfg(test)]
    pub(crate) fn for_test(read_only_db: bool, destructive_write: bool) -> Self {
        Self {
            read_only_db,
            destructive_write,
        }
    }

    /// Whether a specific capability is granted. `Shell` is always `false`.
    /// This is the primitive the gate ([`check`]) is built on, so it is on the
    /// production `tools/call` path even though the shipped read-only tools
    /// require no capabilities (the gate is exercised by negative tests now
    /// and by the deferred destructive tool later).
    pub(crate) fn grants(&self, capability: Capability) -> bool {
        match capability {
            Capability::ReadOnlyDb => self.read_only_db,
            Capability::DestructiveWrite => self.destructive_write,
            Capability::Shell => false,
        }
    }

    /// Returns `Ok(())` if every capability in `required` is granted, or a
    /// [`McpError::CapabilityRefused`] naming the first ungranted capability.
    ///
    /// `Shell` is unconditionally ungranted, so any tool that requires it
    /// is refused here — regardless of how the server was started. This is
    /// the single capability-gating choke point every tool passes through.
    /// Implemented over [`grants`] so there is one grant-decision primitive.
    pub(crate) fn check(
        &self,
        required: &[Capability],
    ) -> Result<(), crate::commands::mcp::error::McpError> {
        use crate::commands::mcp::error::McpError;
        for capability in required {
            if !self.grants(*capability) {
                return Err(McpError::CapabilityRefused {
                    capability: capability.as_str(),
                });
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::McpOptions;

    fn default_options() -> McpOptions {
        McpOptions::default()
    }

    #[test]
    fn default_grants_read_only_db_not_destructive() {
        let caps = CapabilitySet::from_options(&default_options());
        assert!(caps.grants(Capability::ReadOnlyDb));
        assert!(!caps.grants(Capability::DestructiveWrite));
    }

    #[test]
    fn shell_is_never_granted_regardless_of_flags() {
        // There is no flag to enable Shell; from_options never grants it.
        let caps = CapabilitySet::from_options(&McpOptions {
            allow_destructive_writes: true,
        });
        assert!(!caps.grants(Capability::Shell));
    }

    #[test]
    fn destructive_write_requires_the_flag() {
        let on = CapabilitySet::from_options(&McpOptions {
            allow_destructive_writes: true,
        });
        assert!(on.grants(Capability::DestructiveWrite));
        let off = CapabilitySet::from_options(&default_options());
        assert!(!off.grants(Capability::DestructiveWrite));
    }

    #[test]
    fn empty_requirements_pass_with_any_capability_set() {
        // The shipped read-only tools require no capabilities.
        let caps = CapabilitySet::from_options(&default_options());
        assert!(caps.check(&[]).is_ok());
    }

    #[test]
    fn read_only_db_passes_by_default() {
        let caps = CapabilitySet::from_options(&default_options());
        assert!(caps.check(&[Capability::ReadOnlyDb]).is_ok());
    }

    #[test]
    fn destructive_write_refused_by_default() {
        let caps = CapabilitySet::from_options(&default_options());
        let err = caps
            .check(&[Capability::DestructiveWrite])
            .expect_err("refused");
        assert!(
            matches!(err, crate::commands::mcp::error::McpError::CapabilityRefused { capability } if capability == "DestructiveWrite")
        );
    }

    #[test]
    fn destructive_write_passes_when_flagged() {
        let caps = CapabilitySet::from_options(&McpOptions {
            allow_destructive_writes: true,
        });
        assert!(caps.check(&[Capability::DestructiveWrite]).is_ok());
    }

    #[test]
    fn shell_is_always_refused_even_if_a_tool_requires_it() {
        // Even an "everything on" capability set refuses Shell.
        let caps = CapabilitySet::for_test(true, true);
        let err = caps.check(&[Capability::Shell]).expect_err("refused");
        assert!(
            matches!(err, crate::commands::mcp::error::McpError::CapabilityRefused { capability } if capability == "Shell")
        );
    }

    #[test]
    fn check_reports_the_first_ungranted_capability() {
        // DestructiveWrite missing, Shell always missing — DestructiveWrite
        // comes first in the requirement list, so it is the reported one.
        let caps = CapabilitySet::from_options(&default_options());
        let err = caps
            .check(&[Capability::DestructiveWrite, Capability::Shell])
            .expect_err("refused");
        assert!(
            matches!(err, crate::commands::mcp::error::McpError::CapabilityRefused { capability } if capability == "DestructiveWrite")
        );
    }
}