permission_enforcer.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. //! Permission enforcement layer that gates tool execution based on the
  2. //! active `PermissionPolicy`.
  3. use crate::permissions::{PermissionMode, PermissionOutcome, PermissionPolicy};
  4. use serde::{Deserialize, Serialize};
  5. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  6. #[serde(tag = "outcome")]
  7. pub enum EnforcementResult {
  8. /// Tool execution is allowed.
  9. Allowed,
  10. /// Tool execution was denied due to insufficient permissions.
  11. Denied {
  12. tool: String,
  13. active_mode: String,
  14. required_mode: String,
  15. reason: String,
  16. },
  17. }
  18. #[derive(Debug, Clone, PartialEq)]
  19. pub struct PermissionEnforcer {
  20. policy: PermissionPolicy,
  21. }
  22. impl PermissionEnforcer {
  23. #[must_use]
  24. pub fn new(policy: PermissionPolicy) -> Self {
  25. Self { policy }
  26. }
  27. /// Check whether a tool can be executed under the current permission policy.
  28. /// Auto-denies when prompting is required but no prompter is provided.
  29. pub fn check(&self, tool_name: &str, input: &str) -> EnforcementResult {
  30. // When the active mode is Prompt, defer to the caller's interactive
  31. // prompt flow rather than hard-denying (the enforcer has no prompter).
  32. if self.policy.active_mode() == PermissionMode::Prompt {
  33. return EnforcementResult::Allowed;
  34. }
  35. let outcome = self.policy.authorize(tool_name, input, None);
  36. match outcome {
  37. PermissionOutcome::Allow => EnforcementResult::Allowed,
  38. PermissionOutcome::Deny { reason } => {
  39. let active_mode = self.policy.active_mode();
  40. let required_mode = self.policy.required_mode_for(tool_name);
  41. EnforcementResult::Denied {
  42. tool: tool_name.to_owned(),
  43. active_mode: active_mode.as_str().to_owned(),
  44. required_mode: required_mode.as_str().to_owned(),
  45. reason,
  46. }
  47. }
  48. }
  49. }
  50. #[must_use]
  51. pub fn is_allowed(&self, tool_name: &str, input: &str) -> bool {
  52. matches!(self.check(tool_name, input), EnforcementResult::Allowed)
  53. }
  54. #[must_use]
  55. pub fn active_mode(&self) -> PermissionMode {
  56. self.policy.active_mode()
  57. }
  58. /// Classify a file operation against workspace boundaries.
  59. pub fn check_file_write(&self, path: &str, workspace_root: &str) -> EnforcementResult {
  60. let mode = self.policy.active_mode();
  61. match mode {
  62. PermissionMode::ReadOnly => EnforcementResult::Denied {
  63. tool: "write_file".to_owned(),
  64. active_mode: mode.as_str().to_owned(),
  65. required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
  66. reason: format!("file writes are not allowed in '{}' mode", mode.as_str()),
  67. },
  68. PermissionMode::WorkspaceWrite => {
  69. if is_within_workspace(path, workspace_root) {
  70. EnforcementResult::Allowed
  71. } else {
  72. EnforcementResult::Denied {
  73. tool: "write_file".to_owned(),
  74. active_mode: mode.as_str().to_owned(),
  75. required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
  76. reason: format!(
  77. "path '{}' is outside workspace root '{}'",
  78. path, workspace_root
  79. ),
  80. }
  81. }
  82. }
  83. // Allow and DangerFullAccess permit all writes
  84. PermissionMode::Allow | PermissionMode::DangerFullAccess => EnforcementResult::Allowed,
  85. PermissionMode::Prompt => EnforcementResult::Denied {
  86. tool: "write_file".to_owned(),
  87. active_mode: mode.as_str().to_owned(),
  88. required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
  89. reason: "file write requires confirmation in prompt mode".to_owned(),
  90. },
  91. }
  92. }
  93. /// Check if a bash command should be allowed based on current mode.
  94. pub fn check_bash(&self, command: &str) -> EnforcementResult {
  95. let mode = self.policy.active_mode();
  96. match mode {
  97. PermissionMode::ReadOnly => {
  98. if is_read_only_command(command) {
  99. EnforcementResult::Allowed
  100. } else {
  101. EnforcementResult::Denied {
  102. tool: "bash".to_owned(),
  103. active_mode: mode.as_str().to_owned(),
  104. required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
  105. reason: format!(
  106. "command may modify state; not allowed in '{}' mode",
  107. mode.as_str()
  108. ),
  109. }
  110. }
  111. }
  112. PermissionMode::Prompt => EnforcementResult::Denied {
  113. tool: "bash".to_owned(),
  114. active_mode: mode.as_str().to_owned(),
  115. required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
  116. reason: "bash requires confirmation in prompt mode".to_owned(),
  117. },
  118. // WorkspaceWrite, Allow, DangerFullAccess: permit bash
  119. _ => EnforcementResult::Allowed,
  120. }
  121. }
  122. }
  123. /// Simple workspace boundary check via string prefix.
  124. fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
  125. let normalized = if path.starts_with('/') {
  126. path.to_owned()
  127. } else {
  128. format!("{workspace_root}/{path}")
  129. };
  130. let root = if workspace_root.ends_with('/') {
  131. workspace_root.to_owned()
  132. } else {
  133. format!("{workspace_root}/")
  134. };
  135. normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/')
  136. }
  137. /// Conservative heuristic: is this bash command read-only?
  138. fn is_read_only_command(command: &str) -> bool {
  139. let first_token = command
  140. .split_whitespace()
  141. .next()
  142. .unwrap_or("")
  143. .rsplit('/')
  144. .next()
  145. .unwrap_or("");
  146. matches!(
  147. first_token,
  148. "cat"
  149. | "head"
  150. | "tail"
  151. | "less"
  152. | "more"
  153. | "wc"
  154. | "ls"
  155. | "find"
  156. | "grep"
  157. | "rg"
  158. | "awk"
  159. | "sed"
  160. | "echo"
  161. | "printf"
  162. | "which"
  163. | "where"
  164. | "whoami"
  165. | "pwd"
  166. | "env"
  167. | "printenv"
  168. | "date"
  169. | "cal"
  170. | "df"
  171. | "du"
  172. | "free"
  173. | "uptime"
  174. | "uname"
  175. | "file"
  176. | "stat"
  177. | "diff"
  178. | "sort"
  179. | "uniq"
  180. | "tr"
  181. | "cut"
  182. | "paste"
  183. | "tee"
  184. | "xargs"
  185. | "test"
  186. | "true"
  187. | "false"
  188. | "type"
  189. | "readlink"
  190. | "realpath"
  191. | "basename"
  192. | "dirname"
  193. | "sha256sum"
  194. | "md5sum"
  195. | "b3sum"
  196. | "xxd"
  197. | "hexdump"
  198. | "od"
  199. | "strings"
  200. | "tree"
  201. | "jq"
  202. | "yq"
  203. | "python3"
  204. | "python"
  205. | "node"
  206. | "ruby"
  207. | "cargo"
  208. | "rustc"
  209. | "git"
  210. | "gh"
  211. ) && !command.contains("-i ")
  212. && !command.contains("--in-place")
  213. && !command.contains(" > ")
  214. && !command.contains(" >> ")
  215. }
  216. #[cfg(test)]
  217. mod tests {
  218. use super::*;
  219. fn make_enforcer(mode: PermissionMode) -> PermissionEnforcer {
  220. let policy = PermissionPolicy::new(mode);
  221. PermissionEnforcer::new(policy)
  222. }
  223. #[test]
  224. fn allow_mode_permits_everything() {
  225. let enforcer = make_enforcer(PermissionMode::Allow);
  226. assert!(enforcer.is_allowed("bash", ""));
  227. assert!(enforcer.is_allowed("write_file", ""));
  228. assert!(enforcer.is_allowed("edit_file", ""));
  229. assert_eq!(
  230. enforcer.check_file_write("/outside/path", "/workspace"),
  231. EnforcementResult::Allowed
  232. );
  233. assert_eq!(enforcer.check_bash("rm -rf /"), EnforcementResult::Allowed);
  234. }
  235. #[test]
  236. fn read_only_denies_writes() {
  237. let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
  238. .with_tool_requirement("read_file", PermissionMode::ReadOnly)
  239. .with_tool_requirement("grep_search", PermissionMode::ReadOnly)
  240. .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
  241. let enforcer = PermissionEnforcer::new(policy);
  242. assert!(enforcer.is_allowed("read_file", ""));
  243. assert!(enforcer.is_allowed("grep_search", ""));
  244. // write_file requires WorkspaceWrite but we're in ReadOnly
  245. let result = enforcer.check("write_file", "");
  246. assert!(matches!(result, EnforcementResult::Denied { .. }));
  247. let result = enforcer.check_file_write("/workspace/file.rs", "/workspace");
  248. assert!(matches!(result, EnforcementResult::Denied { .. }));
  249. }
  250. #[test]
  251. fn read_only_allows_read_commands() {
  252. let enforcer = make_enforcer(PermissionMode::ReadOnly);
  253. assert_eq!(
  254. enforcer.check_bash("cat src/main.rs"),
  255. EnforcementResult::Allowed
  256. );
  257. assert_eq!(
  258. enforcer.check_bash("grep -r 'pattern' ."),
  259. EnforcementResult::Allowed
  260. );
  261. assert_eq!(enforcer.check_bash("ls -la"), EnforcementResult::Allowed);
  262. }
  263. #[test]
  264. fn read_only_denies_write_commands() {
  265. let enforcer = make_enforcer(PermissionMode::ReadOnly);
  266. let result = enforcer.check_bash("rm file.txt");
  267. assert!(matches!(result, EnforcementResult::Denied { .. }));
  268. }
  269. #[test]
  270. fn workspace_write_allows_within_workspace() {
  271. let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
  272. let result = enforcer.check_file_write("/workspace/src/main.rs", "/workspace");
  273. assert_eq!(result, EnforcementResult::Allowed);
  274. }
  275. #[test]
  276. fn workspace_write_denies_outside_workspace() {
  277. let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
  278. let result = enforcer.check_file_write("/etc/passwd", "/workspace");
  279. assert!(matches!(result, EnforcementResult::Denied { .. }));
  280. }
  281. #[test]
  282. fn prompt_mode_denies_without_prompter() {
  283. let enforcer = make_enforcer(PermissionMode::Prompt);
  284. let result = enforcer.check_bash("echo test");
  285. assert!(matches!(result, EnforcementResult::Denied { .. }));
  286. let result = enforcer.check_file_write("/workspace/file.rs", "/workspace");
  287. assert!(matches!(result, EnforcementResult::Denied { .. }));
  288. }
  289. #[test]
  290. fn workspace_boundary_check() {
  291. assert!(is_within_workspace("/workspace/src/main.rs", "/workspace"));
  292. assert!(is_within_workspace("/workspace", "/workspace"));
  293. assert!(!is_within_workspace("/etc/passwd", "/workspace"));
  294. assert!(!is_within_workspace("/workspacex/hack", "/workspace"));
  295. }
  296. #[test]
  297. fn read_only_command_heuristic() {
  298. assert!(is_read_only_command("cat file.txt"));
  299. assert!(is_read_only_command("grep pattern file"));
  300. assert!(is_read_only_command("git log --oneline"));
  301. assert!(!is_read_only_command("rm file.txt"));
  302. assert!(!is_read_only_command("echo test > file.txt"));
  303. assert!(!is_read_only_command("sed -i 's/a/b/' file"));
  304. }
  305. #[test]
  306. fn active_mode_returns_policy_mode() {
  307. // given
  308. let modes = [
  309. PermissionMode::ReadOnly,
  310. PermissionMode::WorkspaceWrite,
  311. PermissionMode::DangerFullAccess,
  312. PermissionMode::Prompt,
  313. PermissionMode::Allow,
  314. ];
  315. // when
  316. let active_modes: Vec<_> = modes
  317. .into_iter()
  318. .map(|mode| make_enforcer(mode).active_mode())
  319. .collect();
  320. // then
  321. assert_eq!(active_modes, modes);
  322. }
  323. #[test]
  324. fn danger_full_access_permits_file_writes_and_bash() {
  325. // given
  326. let enforcer = make_enforcer(PermissionMode::DangerFullAccess);
  327. // when
  328. let file_result = enforcer.check_file_write("/outside/workspace/file.txt", "/workspace");
  329. let bash_result = enforcer.check_bash("rm -rf /tmp/scratch");
  330. // then
  331. assert_eq!(file_result, EnforcementResult::Allowed);
  332. assert_eq!(bash_result, EnforcementResult::Allowed);
  333. }
  334. #[test]
  335. fn check_denied_payload_contains_tool_and_modes() {
  336. // given
  337. let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
  338. .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
  339. let enforcer = PermissionEnforcer::new(policy);
  340. // when
  341. let result = enforcer.check("write_file", "{}");
  342. // then
  343. match result {
  344. EnforcementResult::Denied {
  345. tool,
  346. active_mode,
  347. required_mode,
  348. reason,
  349. } => {
  350. assert_eq!(tool, "write_file");
  351. assert_eq!(active_mode, "read-only");
  352. assert_eq!(required_mode, "workspace-write");
  353. assert!(reason.contains("requires workspace-write permission"));
  354. }
  355. other => panic!("expected denied result, got {other:?}"),
  356. }
  357. }
  358. #[test]
  359. fn workspace_write_relative_path_resolved() {
  360. // given
  361. let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
  362. // when
  363. let result = enforcer.check_file_write("src/main.rs", "/workspace");
  364. // then
  365. assert_eq!(result, EnforcementResult::Allowed);
  366. }
  367. #[test]
  368. fn workspace_root_with_trailing_slash() {
  369. // given
  370. let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
  371. // when
  372. let result = enforcer.check_file_write("/workspace/src/main.rs", "/workspace/");
  373. // then
  374. assert_eq!(result, EnforcementResult::Allowed);
  375. }
  376. #[test]
  377. fn workspace_root_equality() {
  378. // given
  379. let root = "/workspace/";
  380. // when
  381. let equal_to_root = is_within_workspace("/workspace", root);
  382. // then
  383. assert!(equal_to_root);
  384. }
  385. #[test]
  386. fn bash_heuristic_full_path_prefix() {
  387. // given
  388. let full_path_command = "/usr/bin/cat Cargo.toml";
  389. let git_path_command = "/usr/local/bin/git status";
  390. // when
  391. let cat_result = is_read_only_command(full_path_command);
  392. let git_result = is_read_only_command(git_path_command);
  393. // then
  394. assert!(cat_result);
  395. assert!(git_result);
  396. }
  397. #[test]
  398. fn bash_heuristic_redirects_block_read_only_commands() {
  399. // given
  400. let overwrite = "cat Cargo.toml > out.txt";
  401. let append = "echo test >> out.txt";
  402. // when
  403. let overwrite_result = is_read_only_command(overwrite);
  404. let append_result = is_read_only_command(append);
  405. // then
  406. assert!(!overwrite_result);
  407. assert!(!append_result);
  408. }
  409. #[test]
  410. fn bash_heuristic_in_place_flag_blocks() {
  411. // given
  412. let interactive_python = "python -i script.py";
  413. let in_place_sed = "sed --in-place 's/a/b/' file.txt";
  414. // when
  415. let interactive_result = is_read_only_command(interactive_python);
  416. let in_place_result = is_read_only_command(in_place_sed);
  417. // then
  418. assert!(!interactive_result);
  419. assert!(!in_place_result);
  420. }
  421. #[test]
  422. fn bash_heuristic_empty_command() {
  423. // given
  424. let empty = "";
  425. let whitespace = " ";
  426. // when
  427. let empty_result = is_read_only_command(empty);
  428. let whitespace_result = is_read_only_command(whitespace);
  429. // then
  430. assert!(!empty_result);
  431. assert!(!whitespace_result);
  432. }
  433. #[test]
  434. fn prompt_mode_check_bash_denied_payload_fields() {
  435. // given
  436. let enforcer = make_enforcer(PermissionMode::Prompt);
  437. // when
  438. let result = enforcer.check_bash("git status");
  439. // then
  440. match result {
  441. EnforcementResult::Denied {
  442. tool,
  443. active_mode,
  444. required_mode,
  445. reason,
  446. } => {
  447. assert_eq!(tool, "bash");
  448. assert_eq!(active_mode, "prompt");
  449. assert_eq!(required_mode, "danger-full-access");
  450. assert_eq!(reason, "bash requires confirmation in prompt mode");
  451. }
  452. other => panic!("expected denied result, got {other:?}"),
  453. }
  454. }
  455. #[test]
  456. fn read_only_check_file_write_denied_payload() {
  457. // given
  458. let enforcer = make_enforcer(PermissionMode::ReadOnly);
  459. // when
  460. let result = enforcer.check_file_write("/workspace/file.txt", "/workspace");
  461. // then
  462. match result {
  463. EnforcementResult::Denied {
  464. tool,
  465. active_mode,
  466. required_mode,
  467. reason,
  468. } => {
  469. assert_eq!(tool, "write_file");
  470. assert_eq!(active_mode, "read-only");
  471. assert_eq!(required_mode, "workspace-write");
  472. assert!(reason.contains("file writes are not allowed"));
  473. }
  474. other => panic!("expected denied result, got {other:?}"),
  475. }
  476. }
  477. }