Files
Kigi-CLI/crates/codegen/kigi-workspace/src/capability.rs
T
ZacharyZhang-NY 5e4e24db99 M2 audit: excise managed connectors and xAI media-gen tools
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).
2026-07-17 23:45:05 -04:00

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));
}
}