permissions.rs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. use std::collections::BTreeMap;
  2. use serde_json::Value;
  3. use crate::config::RuntimePermissionRuleConfig;
  4. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
  5. pub enum PermissionMode {
  6. ReadOnly,
  7. WorkspaceWrite,
  8. DangerFullAccess,
  9. Prompt,
  10. Allow,
  11. }
  12. impl PermissionMode {
  13. #[must_use]
  14. pub fn as_str(self) -> &'static str {
  15. match self {
  16. Self::ReadOnly => "read-only",
  17. Self::WorkspaceWrite => "workspace-write",
  18. Self::DangerFullAccess => "danger-full-access",
  19. Self::Prompt => "prompt",
  20. Self::Allow => "allow",
  21. }
  22. }
  23. }
  24. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  25. pub enum PermissionOverride {
  26. Allow,
  27. Deny,
  28. Ask,
  29. }
  30. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  31. pub struct PermissionContext {
  32. override_decision: Option<PermissionOverride>,
  33. override_reason: Option<String>,
  34. }
  35. impl PermissionContext {
  36. #[must_use]
  37. pub fn new(
  38. override_decision: Option<PermissionOverride>,
  39. override_reason: Option<String>,
  40. ) -> Self {
  41. Self {
  42. override_decision,
  43. override_reason,
  44. }
  45. }
  46. #[must_use]
  47. pub fn override_decision(&self) -> Option<PermissionOverride> {
  48. self.override_decision
  49. }
  50. #[must_use]
  51. pub fn override_reason(&self) -> Option<&str> {
  52. self.override_reason.as_deref()
  53. }
  54. }
  55. #[derive(Debug, Clone, PartialEq, Eq)]
  56. pub struct PermissionRequest {
  57. pub tool_name: String,
  58. pub input: String,
  59. pub current_mode: PermissionMode,
  60. pub required_mode: PermissionMode,
  61. pub reason: Option<String>,
  62. }
  63. #[derive(Debug, Clone, PartialEq, Eq)]
  64. pub enum PermissionPromptDecision {
  65. Allow,
  66. Deny { reason: String },
  67. }
  68. pub trait PermissionPrompter {
  69. fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision;
  70. }
  71. #[derive(Debug, Clone, PartialEq, Eq)]
  72. pub enum PermissionOutcome {
  73. Allow,
  74. Deny { reason: String },
  75. }
  76. #[derive(Debug, Clone, PartialEq, Eq)]
  77. pub struct PermissionPolicy {
  78. active_mode: PermissionMode,
  79. tool_requirements: BTreeMap<String, PermissionMode>,
  80. allow_rules: Vec<PermissionRule>,
  81. deny_rules: Vec<PermissionRule>,
  82. ask_rules: Vec<PermissionRule>,
  83. }
  84. impl PermissionPolicy {
  85. #[must_use]
  86. pub fn new(active_mode: PermissionMode) -> Self {
  87. Self {
  88. active_mode,
  89. tool_requirements: BTreeMap::new(),
  90. allow_rules: Vec::new(),
  91. deny_rules: Vec::new(),
  92. ask_rules: Vec::new(),
  93. }
  94. }
  95. #[must_use]
  96. pub fn with_tool_requirement(
  97. mut self,
  98. tool_name: impl Into<String>,
  99. required_mode: PermissionMode,
  100. ) -> Self {
  101. self.tool_requirements
  102. .insert(tool_name.into(), required_mode);
  103. self
  104. }
  105. #[must_use]
  106. pub fn with_permission_rules(mut self, config: &RuntimePermissionRuleConfig) -> Self {
  107. self.allow_rules = config
  108. .allow()
  109. .iter()
  110. .map(|rule| PermissionRule::parse(rule))
  111. .collect();
  112. self.deny_rules = config
  113. .deny()
  114. .iter()
  115. .map(|rule| PermissionRule::parse(rule))
  116. .collect();
  117. self.ask_rules = config
  118. .ask()
  119. .iter()
  120. .map(|rule| PermissionRule::parse(rule))
  121. .collect();
  122. self
  123. }
  124. #[must_use]
  125. pub fn active_mode(&self) -> PermissionMode {
  126. self.active_mode
  127. }
  128. #[must_use]
  129. pub fn required_mode_for(&self, tool_name: &str) -> PermissionMode {
  130. self.tool_requirements
  131. .get(tool_name)
  132. .copied()
  133. .unwrap_or(PermissionMode::DangerFullAccess)
  134. }
  135. #[must_use]
  136. pub fn authorize(
  137. &self,
  138. tool_name: &str,
  139. input: &str,
  140. prompter: Option<&mut dyn PermissionPrompter>,
  141. ) -> PermissionOutcome {
  142. self.authorize_with_context(tool_name, input, &PermissionContext::default(), prompter)
  143. }
  144. #[must_use]
  145. #[allow(clippy::too_many_lines)]
  146. pub fn authorize_with_context(
  147. &self,
  148. tool_name: &str,
  149. input: &str,
  150. context: &PermissionContext,
  151. prompter: Option<&mut dyn PermissionPrompter>,
  152. ) -> PermissionOutcome {
  153. if let Some(rule) = Self::find_matching_rule(&self.deny_rules, tool_name, input) {
  154. return PermissionOutcome::Deny {
  155. reason: format!(
  156. "Permission to use {tool_name} has been denied by rule '{}'",
  157. rule.raw
  158. ),
  159. };
  160. }
  161. let current_mode = self.active_mode();
  162. let required_mode = self.required_mode_for(tool_name);
  163. let ask_rule = Self::find_matching_rule(&self.ask_rules, tool_name, input);
  164. let allow_rule = Self::find_matching_rule(&self.allow_rules, tool_name, input);
  165. match context.override_decision() {
  166. Some(PermissionOverride::Deny) => {
  167. return PermissionOutcome::Deny {
  168. reason: context.override_reason().map_or_else(
  169. || format!("tool '{tool_name}' denied by hook"),
  170. ToOwned::to_owned,
  171. ),
  172. };
  173. }
  174. Some(PermissionOverride::Ask) => {
  175. let reason = context.override_reason().map_or_else(
  176. || format!("tool '{tool_name}' requires approval due to hook guidance"),
  177. ToOwned::to_owned,
  178. );
  179. return Self::prompt_or_deny(
  180. tool_name,
  181. input,
  182. current_mode,
  183. required_mode,
  184. Some(reason),
  185. prompter,
  186. );
  187. }
  188. Some(PermissionOverride::Allow) => {
  189. if let Some(rule) = ask_rule {
  190. let reason = format!(
  191. "tool '{tool_name}' requires approval due to ask rule '{}'",
  192. rule.raw
  193. );
  194. return Self::prompt_or_deny(
  195. tool_name,
  196. input,
  197. current_mode,
  198. required_mode,
  199. Some(reason),
  200. prompter,
  201. );
  202. }
  203. if allow_rule.is_some()
  204. || current_mode == PermissionMode::Allow
  205. || current_mode >= required_mode
  206. {
  207. return PermissionOutcome::Allow;
  208. }
  209. }
  210. None => {}
  211. }
  212. if let Some(rule) = ask_rule {
  213. let reason = format!(
  214. "tool '{tool_name}' requires approval due to ask rule '{}'",
  215. rule.raw
  216. );
  217. return Self::prompt_or_deny(
  218. tool_name,
  219. input,
  220. current_mode,
  221. required_mode,
  222. Some(reason),
  223. prompter,
  224. );
  225. }
  226. if allow_rule.is_some()
  227. || current_mode == PermissionMode::Allow
  228. || current_mode >= required_mode
  229. {
  230. return PermissionOutcome::Allow;
  231. }
  232. if current_mode == PermissionMode::Prompt
  233. || (current_mode == PermissionMode::WorkspaceWrite
  234. && required_mode == PermissionMode::DangerFullAccess)
  235. {
  236. let reason = Some(format!(
  237. "tool '{tool_name}' requires approval to escalate from {} to {}",
  238. current_mode.as_str(),
  239. required_mode.as_str()
  240. ));
  241. return Self::prompt_or_deny(
  242. tool_name,
  243. input,
  244. current_mode,
  245. required_mode,
  246. reason,
  247. prompter,
  248. );
  249. }
  250. PermissionOutcome::Deny {
  251. reason: format!(
  252. "tool '{tool_name}' requires {} permission; current mode is {}",
  253. required_mode.as_str(),
  254. current_mode.as_str()
  255. ),
  256. }
  257. }
  258. fn prompt_or_deny(
  259. tool_name: &str,
  260. input: &str,
  261. current_mode: PermissionMode,
  262. required_mode: PermissionMode,
  263. reason: Option<String>,
  264. mut prompter: Option<&mut dyn PermissionPrompter>,
  265. ) -> PermissionOutcome {
  266. let request = PermissionRequest {
  267. tool_name: tool_name.to_string(),
  268. input: input.to_string(),
  269. current_mode,
  270. required_mode,
  271. reason: reason.clone(),
  272. };
  273. match prompter.as_mut() {
  274. Some(prompter) => match prompter.decide(&request) {
  275. PermissionPromptDecision::Allow => PermissionOutcome::Allow,
  276. PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
  277. },
  278. None => PermissionOutcome::Deny {
  279. reason: reason.unwrap_or_else(|| {
  280. format!(
  281. "tool '{tool_name}' requires approval to run while mode is {}",
  282. current_mode.as_str()
  283. )
  284. }),
  285. },
  286. }
  287. }
  288. fn find_matching_rule<'a>(
  289. rules: &'a [PermissionRule],
  290. tool_name: &str,
  291. input: &str,
  292. ) -> Option<&'a PermissionRule> {
  293. rules.iter().find(|rule| rule.matches(tool_name, input))
  294. }
  295. }
  296. #[derive(Debug, Clone, PartialEq, Eq)]
  297. struct PermissionRule {
  298. raw: String,
  299. tool_name: String,
  300. matcher: PermissionRuleMatcher,
  301. }
  302. #[derive(Debug, Clone, PartialEq, Eq)]
  303. enum PermissionRuleMatcher {
  304. Any,
  305. Exact(String),
  306. Prefix(String),
  307. }
  308. impl PermissionRule {
  309. fn parse(raw: &str) -> Self {
  310. let trimmed = raw.trim();
  311. let open = find_first_unescaped(trimmed, '(');
  312. let close = find_last_unescaped(trimmed, ')');
  313. if let (Some(open), Some(close)) = (open, close) {
  314. if close == trimmed.len() - 1 && open < close {
  315. let tool_name = trimmed[..open].trim();
  316. let content = &trimmed[open + 1..close];
  317. if !tool_name.is_empty() {
  318. let matcher = parse_rule_matcher(content);
  319. return Self {
  320. raw: trimmed.to_string(),
  321. tool_name: tool_name.to_string(),
  322. matcher,
  323. };
  324. }
  325. }
  326. }
  327. Self {
  328. raw: trimmed.to_string(),
  329. tool_name: trimmed.to_string(),
  330. matcher: PermissionRuleMatcher::Any,
  331. }
  332. }
  333. fn matches(&self, tool_name: &str, input: &str) -> bool {
  334. if self.tool_name != tool_name {
  335. return false;
  336. }
  337. match &self.matcher {
  338. PermissionRuleMatcher::Any => true,
  339. PermissionRuleMatcher::Exact(expected) => {
  340. extract_permission_subject(input).is_some_and(|candidate| candidate == *expected)
  341. }
  342. PermissionRuleMatcher::Prefix(prefix) => extract_permission_subject(input)
  343. .is_some_and(|candidate| candidate.starts_with(prefix)),
  344. }
  345. }
  346. }
  347. fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher {
  348. let unescaped = unescape_rule_content(content.trim());
  349. if unescaped.is_empty() || unescaped == "*" {
  350. PermissionRuleMatcher::Any
  351. } else if let Some(prefix) = unescaped.strip_suffix(":*") {
  352. PermissionRuleMatcher::Prefix(prefix.to_string())
  353. } else {
  354. PermissionRuleMatcher::Exact(unescaped)
  355. }
  356. }
  357. fn unescape_rule_content(content: &str) -> String {
  358. content
  359. .replace(r"\(", "(")
  360. .replace(r"\)", ")")
  361. .replace(r"\\", r"\")
  362. }
  363. fn find_first_unescaped(value: &str, needle: char) -> Option<usize> {
  364. let mut escaped = false;
  365. for (idx, ch) in value.char_indices() {
  366. if ch == '\\' {
  367. escaped = !escaped;
  368. continue;
  369. }
  370. if ch == needle && !escaped {
  371. return Some(idx);
  372. }
  373. escaped = false;
  374. }
  375. None
  376. }
  377. fn find_last_unescaped(value: &str, needle: char) -> Option<usize> {
  378. let chars = value.char_indices().collect::<Vec<_>>();
  379. for (pos, (idx, ch)) in chars.iter().enumerate().rev() {
  380. if *ch != needle {
  381. continue;
  382. }
  383. let mut backslashes = 0;
  384. for (_, prev) in chars[..pos].iter().rev() {
  385. if *prev == '\\' {
  386. backslashes += 1;
  387. } else {
  388. break;
  389. }
  390. }
  391. if backslashes % 2 == 0 {
  392. return Some(*idx);
  393. }
  394. }
  395. None
  396. }
  397. fn extract_permission_subject(input: &str) -> Option<String> {
  398. let parsed = serde_json::from_str::<Value>(input).ok();
  399. if let Some(Value::Object(object)) = parsed {
  400. for key in [
  401. "command",
  402. "path",
  403. "file_path",
  404. "filePath",
  405. "notebook_path",
  406. "notebookPath",
  407. "url",
  408. "pattern",
  409. "code",
  410. "message",
  411. ] {
  412. if let Some(value) = object.get(key).and_then(Value::as_str) {
  413. return Some(value.to_string());
  414. }
  415. }
  416. }
  417. (!input.trim().is_empty()).then(|| input.to_string())
  418. }
  419. #[cfg(test)]
  420. mod tests {
  421. use super::{
  422. PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
  423. PermissionPromptDecision, PermissionPrompter, PermissionRequest,
  424. };
  425. use crate::config::RuntimePermissionRuleConfig;
  426. struct RecordingPrompter {
  427. seen: Vec<PermissionRequest>,
  428. allow: bool,
  429. }
  430. impl PermissionPrompter for RecordingPrompter {
  431. fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
  432. self.seen.push(request.clone());
  433. if self.allow {
  434. PermissionPromptDecision::Allow
  435. } else {
  436. PermissionPromptDecision::Deny {
  437. reason: "not now".to_string(),
  438. }
  439. }
  440. }
  441. }
  442. #[test]
  443. fn allows_tools_when_active_mode_meets_requirement() {
  444. let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
  445. .with_tool_requirement("read_file", PermissionMode::ReadOnly)
  446. .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
  447. assert_eq!(
  448. policy.authorize("read_file", "{}", None),
  449. PermissionOutcome::Allow
  450. );
  451. assert_eq!(
  452. policy.authorize("write_file", "{}", None),
  453. PermissionOutcome::Allow
  454. );
  455. }
  456. #[test]
  457. fn denies_read_only_escalations_without_prompt() {
  458. let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
  459. .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite)
  460. .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
  461. assert!(matches!(
  462. policy.authorize("write_file", "{}", None),
  463. PermissionOutcome::Deny { reason } if reason.contains("requires workspace-write permission")
  464. ));
  465. assert!(matches!(
  466. policy.authorize("bash", "{}", None),
  467. PermissionOutcome::Deny { reason } if reason.contains("requires danger-full-access permission")
  468. ));
  469. }
  470. #[test]
  471. fn prompts_for_workspace_write_to_danger_full_access_escalation() {
  472. let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
  473. .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
  474. let mut prompter = RecordingPrompter {
  475. seen: Vec::new(),
  476. allow: true,
  477. };
  478. let outcome = policy.authorize("bash", "echo hi", Some(&mut prompter));
  479. assert_eq!(outcome, PermissionOutcome::Allow);
  480. assert_eq!(prompter.seen.len(), 1);
  481. assert_eq!(prompter.seen[0].tool_name, "bash");
  482. assert_eq!(
  483. prompter.seen[0].current_mode,
  484. PermissionMode::WorkspaceWrite
  485. );
  486. assert_eq!(
  487. prompter.seen[0].required_mode,
  488. PermissionMode::DangerFullAccess
  489. );
  490. }
  491. #[test]
  492. fn honors_prompt_rejection_reason() {
  493. let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
  494. .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
  495. let mut prompter = RecordingPrompter {
  496. seen: Vec::new(),
  497. allow: false,
  498. };
  499. assert!(matches!(
  500. policy.authorize("bash", "echo hi", Some(&mut prompter)),
  501. PermissionOutcome::Deny { reason } if reason == "not now"
  502. ));
  503. }
  504. #[test]
  505. fn applies_rule_based_denials_and_allows() {
  506. let rules = RuntimePermissionRuleConfig::new(
  507. vec!["bash(git:*)".to_string()],
  508. vec!["bash(rm -rf:*)".to_string()],
  509. Vec::new(),
  510. );
  511. let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
  512. .with_tool_requirement("bash", PermissionMode::DangerFullAccess)
  513. .with_permission_rules(&rules);
  514. assert_eq!(
  515. policy.authorize("bash", r#"{"command":"git status"}"#, None),
  516. PermissionOutcome::Allow
  517. );
  518. assert!(matches!(
  519. policy.authorize("bash", r#"{"command":"rm -rf /tmp/x"}"#, None),
  520. PermissionOutcome::Deny { reason } if reason.contains("denied by rule")
  521. ));
  522. }
  523. #[test]
  524. fn ask_rules_force_prompt_even_when_mode_allows() {
  525. let rules = RuntimePermissionRuleConfig::new(
  526. Vec::new(),
  527. Vec::new(),
  528. vec!["bash(git:*)".to_string()],
  529. );
  530. let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
  531. .with_tool_requirement("bash", PermissionMode::DangerFullAccess)
  532. .with_permission_rules(&rules);
  533. let mut prompter = RecordingPrompter {
  534. seen: Vec::new(),
  535. allow: true,
  536. };
  537. let outcome = policy.authorize("bash", r#"{"command":"git status"}"#, Some(&mut prompter));
  538. assert_eq!(outcome, PermissionOutcome::Allow);
  539. assert_eq!(prompter.seen.len(), 1);
  540. assert!(prompter.seen[0]
  541. .reason
  542. .as_deref()
  543. .is_some_and(|reason| reason.contains("ask rule")));
  544. }
  545. #[test]
  546. fn hook_allow_still_respects_ask_rules() {
  547. let rules = RuntimePermissionRuleConfig::new(
  548. Vec::new(),
  549. Vec::new(),
  550. vec!["bash(git:*)".to_string()],
  551. );
  552. let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
  553. .with_tool_requirement("bash", PermissionMode::DangerFullAccess)
  554. .with_permission_rules(&rules);
  555. let context = PermissionContext::new(
  556. Some(PermissionOverride::Allow),
  557. Some("hook approved".to_string()),
  558. );
  559. let mut prompter = RecordingPrompter {
  560. seen: Vec::new(),
  561. allow: true,
  562. };
  563. let outcome = policy.authorize_with_context(
  564. "bash",
  565. r#"{"command":"git status"}"#,
  566. &context,
  567. Some(&mut prompter),
  568. );
  569. assert_eq!(outcome, PermissionOutcome::Allow);
  570. assert_eq!(prompter.seen.len(), 1);
  571. }
  572. #[test]
  573. fn hook_deny_short_circuits_permission_flow() {
  574. let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
  575. .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
  576. let context = PermissionContext::new(
  577. Some(PermissionOverride::Deny),
  578. Some("blocked by hook".to_string()),
  579. );
  580. assert_eq!(
  581. policy.authorize_with_context("bash", "{}", &context, None),
  582. PermissionOutcome::Deny {
  583. reason: "blocked by hook".to_string(),
  584. }
  585. );
  586. }
  587. #[test]
  588. fn hook_ask_forces_prompt() {
  589. let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
  590. .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
  591. let context = PermissionContext::new(
  592. Some(PermissionOverride::Ask),
  593. Some("hook requested confirmation".to_string()),
  594. );
  595. let mut prompter = RecordingPrompter {
  596. seen: Vec::new(),
  597. allow: true,
  598. };
  599. let outcome = policy.authorize_with_context("bash", "{}", &context, Some(&mut prompter));
  600. assert_eq!(outcome, PermissionOutcome::Allow);
  601. assert_eq!(prompter.seen.len(), 1);
  602. assert_eq!(
  603. prompter.seen[0].reason.as_deref(),
  604. Some("hook requested confirmation")
  605. );
  606. }
  607. }