Managed connectors (grok.com MCP admin) removed root-and-branch: - The managed-MCP fetch/injection pipeline is gone, including the whole kigi-shell-session-support crate (managed-config fetch client, gateway tool catalog + dispatch, header injection, refresh task), reactive managed re-auth, mcp_doctor's grok.com-source discovery, and the [managed_mcps] config surface. - TUI: the 'Managed by grok.com' section, connectors URL/deep-link, Action::OpenManagedConnectors, and session_team_id are gone. Local MCP management (list/toggle/add/remove/auth/tools) is fully intact. - Kept as LOCAL policy: managed-settings.json MCP allow/deny enforcement, the multi-source local MCP merge, folder-trust gating. PluginOrigin Project/User labels kept (they tag locally discovered plugin dirs). imagine/media-gen tools (xAI image/video generation) removed: - image_gen, image_edit, video_gen, image_to_video, reference_to_video implementations, registrations, ToolKind/ToolInput/Output variants (serde-safe), config plumbing end to end, ZDR video machinery, /imagine + /imagine-video commands and guidance text, the bundled imagine skill (added to legacy cleanup so user installs delete it), and the media-gen render path. - Kept: image INPUT (paste/attach, [Image #N] meta, pdf/image fetch, clipboard wrap), generic media-ref rendering, and the generic tool 401-retry machinery (tests renamed, assertions unweakened). - deploy_app stays: it is a permanently-disabled local stub deploying nowhere. 121 files changed, 8 deleted. Gates: workspace check/clippy 0/0, fmt, deny ok; suites green (tools 2554, shell 4862, tui 6608, workspace 1042). Remaining grok.com strings live only in the auth-method ids and changelog archives (§9/M3 sweep).
328 lines
11 KiB
Rust
328 lines
11 KiB
Rust
//! Capability-mode filtering for session toolsets.
|
|
|
|
use kigi_tools::registry::types::{ToolConfig, ToolServerConfig};
|
|
use kigi_tools::types::tool::ToolKind;
|
|
|
|
/// Capability mode applied to a session's toolset.
|
|
///
|
|
/// A partial order is defined via [`CapabilityMode::is_subset_of`]:
|
|
/// `ReadOnly < ReadWrite < All` and `ReadOnly < Execute < All`.
|
|
/// `ReadWrite` and `Execute` are *incomparable* (neither is a subset
|
|
/// of the other). `fork_session` enforces `child <= parent`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum CapabilityMode {
|
|
/// Reading and searching only. No edits, no shell, no background tasks.
|
|
ReadOnly,
|
|
/// Read + edit. No shell execution.
|
|
ReadWrite,
|
|
/// Read + shell execution + background-task control. No edits.
|
|
Execute,
|
|
/// Every tool kind allowed.
|
|
All,
|
|
}
|
|
|
|
impl Default for CapabilityMode {
|
|
/// Defaults to [`CapabilityMode::ReadWrite`] (subagent default;
|
|
/// the main/root session is always `All`).
|
|
fn default() -> Self {
|
|
Self::ReadWrite
|
|
}
|
|
}
|
|
|
|
impl CapabilityMode {
|
|
/// Filter `config.tools` by capability mode, returning a copy with
|
|
/// disallowed tools dropped.
|
|
///
|
|
/// Tools whose `kind` is `None` (baseline, e.g. ad-hoc tools
|
|
/// declared via `ToolConfig::simple`) are preserved across all
|
|
/// modes. **MCP-origin** `kind: None` tools are NOT preserved by
|
|
/// this method; see `resolve_session_toolset` for the asymmetric
|
|
/// handling.
|
|
pub fn filter(self, config: &ToolServerConfig) -> ToolServerConfig {
|
|
let kept: Vec<ToolConfig> = config
|
|
.tools
|
|
.iter()
|
|
.filter(|tool| match tool.kind {
|
|
None => true,
|
|
Some(kind) => kind_allowed(self, kind),
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
ToolServerConfig {
|
|
tools: kept,
|
|
behavior_preset: config.behavior_preset.clone(),
|
|
}
|
|
}
|
|
|
|
/// Whether every kind allowed by `self` is also allowed by `other`.
|
|
/// Used by `fork_session` to reject capability widening.
|
|
pub fn is_subset_of(self, other: CapabilityMode) -> bool {
|
|
for kind in ALL_TOOL_KINDS {
|
|
if kind_allowed(self, *kind) && !kind_allowed(other, *kind) {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Every `ToolKind` variant. Used by `is_subset_of` and by parameterised
|
|
/// tests. When a new variant is added to `ToolKind`, the compile-time
|
|
/// assertion below fires so it can't be silently omitted.
|
|
pub(crate) const ALL_TOOL_KINDS: &[ToolKind] = &[
|
|
ToolKind::Read,
|
|
ToolKind::Edit,
|
|
ToolKind::Delete,
|
|
ToolKind::ListDir,
|
|
ToolKind::Write,
|
|
ToolKind::Move,
|
|
ToolKind::Search,
|
|
ToolKind::Lsp,
|
|
ToolKind::Execute,
|
|
ToolKind::Plan,
|
|
ToolKind::WebSearch,
|
|
ToolKind::WebFetch,
|
|
ToolKind::BackgroundTaskAction,
|
|
ToolKind::WaitTasksAction,
|
|
ToolKind::KillTaskAction,
|
|
ToolKind::List,
|
|
ToolKind::Skill,
|
|
ToolKind::MemorySearch,
|
|
ToolKind::MemoryGet,
|
|
ToolKind::Task,
|
|
ToolKind::EnterPlan,
|
|
ToolKind::ExitPlan,
|
|
ToolKind::AskUser,
|
|
ToolKind::DeployApp,
|
|
ToolKind::SearchTool,
|
|
ToolKind::UseTool,
|
|
ToolKind::Monitor,
|
|
ToolKind::GoalUpdate,
|
|
ToolKind::Other,
|
|
];
|
|
|
|
// Compile-time guard: if a new `ToolKind` variant is added but not listed in
|
|
// `ALL_TOOL_KINDS`, this assertion fails.
|
|
const _: () = assert!(
|
|
ALL_TOOL_KINDS.len() == ToolKind::VARIANT_COUNT,
|
|
"ALL_TOOL_KINDS is out of sync with ToolKind — add the new variant"
|
|
);
|
|
|
|
/// Maps `(CapabilityMode, ToolKind)` -> kept-or-dropped.
|
|
///
|
|
/// This `match` is intentionally exhaustive: when `ToolKind` gains a
|
|
/// new variant the compiler errors here, forcing a triage decision.
|
|
pub(crate) fn kind_allowed(mode: CapabilityMode, kind: ToolKind) -> bool {
|
|
use CapabilityMode as M;
|
|
use ToolKind::*;
|
|
|
|
if matches!(mode, M::All) {
|
|
return true;
|
|
}
|
|
|
|
match kind {
|
|
// Meta tools: always allowed.
|
|
Plan | EnterPlan | ExitPlan | AskUser | Skill | SearchTool | GoalUpdate => true,
|
|
|
|
// Read class.
|
|
Read | MemoryGet | MemorySearch => {
|
|
matches!(mode, M::ReadOnly | M::ReadWrite | M::Execute)
|
|
}
|
|
|
|
// Search class.
|
|
Search | WebSearch | WebFetch => {
|
|
matches!(mode, M::ReadOnly | M::ReadWrite | M::Execute)
|
|
}
|
|
|
|
// Inspect class.
|
|
Lsp | ListDir | List => matches!(mode, M::ReadOnly | M::ReadWrite | M::Execute),
|
|
|
|
// Edit class.
|
|
Edit | Write | Delete | Move | DeployApp => matches!(mode, M::ReadWrite),
|
|
|
|
// Bash / shell.
|
|
Execute => matches!(mode, M::Execute),
|
|
|
|
// Process control (background tasks, monitors).
|
|
BackgroundTaskAction | WaitTasksAction | KillTaskAction | Task | Monitor => {
|
|
matches!(mode, M::Execute)
|
|
}
|
|
|
|
// Integration dispatch.
|
|
UseTool => matches!(mode, M::ReadWrite | M::Execute),
|
|
|
|
// Catch-all -- only `All` mode keeps it (early-return above).
|
|
Other => false,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::session::tool_config::test_support;
|
|
use kigi_tools::types::tool::ToolKind;
|
|
|
|
fn make_cfg(tools: Vec<ToolConfig>) -> ToolServerConfig {
|
|
ToolServerConfig {
|
|
tools,
|
|
behavior_preset: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn capability_mode_filter_table_is_exhaustive_per_kind() {
|
|
for &mode in &[
|
|
CapabilityMode::ReadOnly,
|
|
CapabilityMode::ReadWrite,
|
|
CapabilityMode::Execute,
|
|
CapabilityMode::All,
|
|
] {
|
|
for &kind in ALL_TOOL_KINDS {
|
|
let id = format!("kind_{kind:?}");
|
|
let cfg = make_cfg(vec![test_support::tc(&id, Some(kind))]);
|
|
let out = mode.filter(&cfg);
|
|
let expected_present = kind_allowed(mode, kind);
|
|
let actually_present = out.tools.iter().any(|t| t.id == id);
|
|
assert_eq!(
|
|
actually_present, expected_present,
|
|
"({mode:?}, {kind:?}): expected present={expected_present}, got {actually_present}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn capability_mode_filter_anchored_membership() {
|
|
let cfg = make_cfg(vec![
|
|
test_support::tc("read", Some(ToolKind::Read)),
|
|
test_support::tc("search", Some(ToolKind::Search)),
|
|
test_support::tc("inspect", Some(ToolKind::Lsp)),
|
|
test_support::tc("edit", Some(ToolKind::Edit)),
|
|
test_support::tc("write", Some(ToolKind::Write)),
|
|
test_support::tc("bash", Some(ToolKind::Execute)),
|
|
test_support::tc("bg", Some(ToolKind::BackgroundTaskAction)),
|
|
test_support::tc("plan", Some(ToolKind::Plan)),
|
|
test_support::tc("ask", Some(ToolKind::AskUser)),
|
|
test_support::tc("other", Some(ToolKind::Other)),
|
|
]);
|
|
|
|
let names = |c: &ToolServerConfig| -> Vec<String> {
|
|
c.tools.iter().map(|t| t.id.clone()).collect()
|
|
};
|
|
|
|
let ro = CapabilityMode::ReadOnly.filter(&cfg);
|
|
assert_eq!(names(&ro), vec!["read", "search", "inspect", "plan", "ask"]);
|
|
|
|
let rw = CapabilityMode::ReadWrite.filter(&cfg);
|
|
assert_eq!(
|
|
names(&rw),
|
|
vec!["read", "search", "inspect", "edit", "write", "plan", "ask"]
|
|
);
|
|
|
|
let ex = CapabilityMode::Execute.filter(&cfg);
|
|
assert_eq!(
|
|
names(&ex),
|
|
vec!["read", "search", "inspect", "bash", "bg", "plan", "ask"]
|
|
);
|
|
|
|
let all = CapabilityMode::All.filter(&cfg);
|
|
assert_eq!(
|
|
names(&all),
|
|
vec![
|
|
"read", "search", "inspect", "edit", "write", "bash", "bg", "plan", "ask", "other"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn capability_mode_baseline_kind_none_always_kept_via_filter() {
|
|
let cfg = make_cfg(vec![
|
|
test_support::tc("baseline.opaque", None),
|
|
test_support::tc("baseline.also_opaque", None),
|
|
test_support::tc("edit_dropped", Some(ToolKind::Edit)),
|
|
]);
|
|
|
|
for mode in [
|
|
CapabilityMode::ReadOnly,
|
|
CapabilityMode::ReadWrite,
|
|
CapabilityMode::Execute,
|
|
CapabilityMode::All,
|
|
] {
|
|
let filtered = mode.filter(&cfg);
|
|
let ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect();
|
|
assert!(
|
|
ids.contains(&"baseline.opaque"),
|
|
"kind: None tool dropped under {mode:?}: {ids:?}"
|
|
);
|
|
assert!(
|
|
ids.contains(&"baseline.also_opaque"),
|
|
"kind: None tool dropped under {mode:?}: {ids:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn capability_mode_preserves_behavior_preset_across_all_modes() {
|
|
let mut cfg = make_cfg(vec![test_support::tc("read", Some(ToolKind::Read))]);
|
|
cfg.behavior_preset = Some("current".to_owned());
|
|
for mode in [
|
|
CapabilityMode::ReadOnly,
|
|
CapabilityMode::ReadWrite,
|
|
CapabilityMode::Execute,
|
|
CapabilityMode::All,
|
|
] {
|
|
let out = mode.filter(&cfg);
|
|
assert_eq!(
|
|
out.behavior_preset.as_deref(),
|
|
Some("current"),
|
|
"behavior_preset lost under {mode:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// is_subset_of partial order
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn capability_mode_is_subset_of_reflexive() {
|
|
for &m in &[
|
|
CapabilityMode::ReadOnly,
|
|
CapabilityMode::ReadWrite,
|
|
CapabilityMode::Execute,
|
|
CapabilityMode::All,
|
|
] {
|
|
assert!(m.is_subset_of(m), "{m:?} must be a subset of itself");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn capability_mode_is_subset_of_strict_chains() {
|
|
assert!(CapabilityMode::ReadOnly.is_subset_of(CapabilityMode::ReadWrite));
|
|
assert!(CapabilityMode::ReadOnly.is_subset_of(CapabilityMode::All));
|
|
assert!(CapabilityMode::ReadWrite.is_subset_of(CapabilityMode::All));
|
|
assert!(CapabilityMode::ReadOnly.is_subset_of(CapabilityMode::Execute));
|
|
assert!(CapabilityMode::Execute.is_subset_of(CapabilityMode::All));
|
|
}
|
|
|
|
#[test]
|
|
fn capability_mode_is_subset_of_widening_rejected() {
|
|
assert!(!CapabilityMode::All.is_subset_of(CapabilityMode::ReadOnly));
|
|
assert!(!CapabilityMode::ReadWrite.is_subset_of(CapabilityMode::ReadOnly));
|
|
assert!(!CapabilityMode::Execute.is_subset_of(CapabilityMode::ReadOnly));
|
|
assert!(!CapabilityMode::All.is_subset_of(CapabilityMode::ReadWrite));
|
|
assert!(!CapabilityMode::All.is_subset_of(CapabilityMode::Execute));
|
|
}
|
|
|
|
#[test]
|
|
fn capability_mode_is_subset_of_incomparable_pairs() {
|
|
assert!(!CapabilityMode::ReadWrite.is_subset_of(CapabilityMode::Execute));
|
|
assert!(!CapabilityMode::Execute.is_subset_of(CapabilityMode::ReadWrite));
|
|
}
|
|
}
|