Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

Commit 336f820

Browse files
author
Jobdori
committed
Merge jobdori/permission-enforcement: PermissionEnforcer with workspace + bash enforcement
2 parents d7f0dc6 + 66283f4 commit 336f820

File tree

3 files changed

+357
-0
lines changed

3 files changed

+357
-0
lines changed

rust/crates/runtime/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod mcp_client;
1212
mod mcp_stdio;
1313
pub mod mcp_tool_bridge;
1414
mod oauth;
15+
pub mod permission_enforcer;
1516
mod permissions;
1617
mod prompt;
1718
mod remote;
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
//! Permission enforcement layer that gates tool execution based on the
2+
//! active `PermissionPolicy`.
3+
//!
4+
//! This module provides `PermissionEnforcer` which wraps tool dispatch
5+
//! and validates that the active permission mode allows the requested tool
6+
//! before executing it.
7+
8+
use crate::permissions::{PermissionMode, PermissionOutcome, PermissionPolicy};
9+
use serde::{Deserialize, Serialize};
10+
11+
/// Result of a permission check before tool execution.
12+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13+
#[serde(tag = "outcome")]
14+
pub enum EnforcementResult {
15+
/// Tool execution is allowed.
16+
Allowed,
17+
/// Tool execution was denied due to insufficient permissions.
18+
Denied {
19+
tool: String,
20+
active_mode: String,
21+
required_mode: String,
22+
reason: String,
23+
},
24+
}
25+
26+
/// Permission enforcer that gates tool execution through the permission policy.
27+
#[derive(Debug, Clone)]
28+
pub struct PermissionEnforcer {
29+
policy: PermissionPolicy,
30+
}
31+
32+
impl PermissionEnforcer {
33+
#[must_use]
34+
pub fn new(policy: PermissionPolicy) -> Self {
35+
Self { policy }
36+
}
37+
38+
/// Check whether a tool can be executed under the current permission policy.
39+
/// Uses the policy's `authorize` method with no prompter (auto-deny on prompt-required).
40+
pub fn check(&self, tool_name: &str, input: &str) -> EnforcementResult {
41+
let outcome = self.policy.authorize(tool_name, input, None);
42+
43+
match outcome {
44+
PermissionOutcome::Allow => EnforcementResult::Allowed,
45+
PermissionOutcome::Deny { reason } => {
46+
let active_mode = self.policy.active_mode();
47+
let required_mode = self.policy.required_mode_for(tool_name);
48+
EnforcementResult::Denied {
49+
tool: tool_name.to_owned(),
50+
active_mode: active_mode.as_str().to_owned(),
51+
required_mode: required_mode.as_str().to_owned(),
52+
reason,
53+
}
54+
}
55+
}
56+
}
57+
58+
/// Check if a tool is allowed (returns true for Allow, false for Deny).
59+
#[must_use]
60+
pub fn is_allowed(&self, tool_name: &str, input: &str) -> bool {
61+
matches!(self.check(tool_name, input), EnforcementResult::Allowed)
62+
}
63+
64+
/// Get the active permission mode.
65+
#[must_use]
66+
pub fn active_mode(&self) -> PermissionMode {
67+
self.policy.active_mode()
68+
}
69+
70+
/// Classify a file operation against workspace boundaries.
71+
pub fn check_file_write(&self, path: &str, workspace_root: &str) -> EnforcementResult {
72+
let mode = self.policy.active_mode();
73+
74+
match mode {
75+
PermissionMode::ReadOnly => EnforcementResult::Denied {
76+
tool: "write_file".to_owned(),
77+
active_mode: mode.as_str().to_owned(),
78+
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
79+
reason: format!("file writes are not allowed in '{}' mode", mode.as_str()),
80+
},
81+
PermissionMode::WorkspaceWrite => {
82+
if is_within_workspace(path, workspace_root) {
83+
EnforcementResult::Allowed
84+
} else {
85+
EnforcementResult::Denied {
86+
tool: "write_file".to_owned(),
87+
active_mode: mode.as_str().to_owned(),
88+
required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
89+
reason: format!(
90+
"path '{}' is outside workspace root '{}'",
91+
path, workspace_root
92+
),
93+
}
94+
}
95+
}
96+
// Allow and DangerFullAccess permit all writes
97+
PermissionMode::Allow | PermissionMode::DangerFullAccess => EnforcementResult::Allowed,
98+
PermissionMode::Prompt => EnforcementResult::Denied {
99+
tool: "write_file".to_owned(),
100+
active_mode: mode.as_str().to_owned(),
101+
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
102+
reason: "file write requires confirmation in prompt mode".to_owned(),
103+
},
104+
}
105+
}
106+
107+
/// Check if a bash command should be allowed based on current mode.
108+
pub fn check_bash(&self, command: &str) -> EnforcementResult {
109+
let mode = self.policy.active_mode();
110+
111+
match mode {
112+
PermissionMode::ReadOnly => {
113+
if is_read_only_command(command) {
114+
EnforcementResult::Allowed
115+
} else {
116+
EnforcementResult::Denied {
117+
tool: "bash".to_owned(),
118+
active_mode: mode.as_str().to_owned(),
119+
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
120+
reason: format!(
121+
"command may modify state; not allowed in '{}' mode",
122+
mode.as_str()
123+
),
124+
}
125+
}
126+
}
127+
PermissionMode::Prompt => EnforcementResult::Denied {
128+
tool: "bash".to_owned(),
129+
active_mode: mode.as_str().to_owned(),
130+
required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
131+
reason: "bash requires confirmation in prompt mode".to_owned(),
132+
},
133+
// WorkspaceWrite, Allow, DangerFullAccess: permit bash
134+
_ => EnforcementResult::Allowed,
135+
}
136+
}
137+
}
138+
139+
/// Simple workspace boundary check via string prefix.
140+
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
141+
let normalized = if path.starts_with('/') {
142+
path.to_owned()
143+
} else {
144+
format!("{workspace_root}/{path}")
145+
};
146+
147+
let root = if workspace_root.ends_with('/') {
148+
workspace_root.to_owned()
149+
} else {
150+
format!("{workspace_root}/")
151+
};
152+
153+
normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/')
154+
}
155+
156+
/// Conservative heuristic: is this bash command read-only?
157+
fn is_read_only_command(command: &str) -> bool {
158+
let first_token = command
159+
.split_whitespace()
160+
.next()
161+
.unwrap_or("")
162+
.rsplit('/')
163+
.next()
164+
.unwrap_or("");
165+
166+
matches!(
167+
first_token,
168+
"cat"
169+
| "head"
170+
| "tail"
171+
| "less"
172+
| "more"
173+
| "wc"
174+
| "ls"
175+
| "find"
176+
| "grep"
177+
| "rg"
178+
| "awk"
179+
| "sed"
180+
| "echo"
181+
| "printf"
182+
| "which"
183+
| "where"
184+
| "whoami"
185+
| "pwd"
186+
| "env"
187+
| "printenv"
188+
| "date"
189+
| "cal"
190+
| "df"
191+
| "du"
192+
| "free"
193+
| "uptime"
194+
| "uname"
195+
| "file"
196+
| "stat"
197+
| "diff"
198+
| "sort"
199+
| "uniq"
200+
| "tr"
201+
| "cut"
202+
| "paste"
203+
| "tee"
204+
| "xargs"
205+
| "test"
206+
| "true"
207+
| "false"
208+
| "type"
209+
| "readlink"
210+
| "realpath"
211+
| "basename"
212+
| "dirname"
213+
| "sha256sum"
214+
| "md5sum"
215+
| "b3sum"
216+
| "xxd"
217+
| "hexdump"
218+
| "od"
219+
| "strings"
220+
| "tree"
221+
| "jq"
222+
| "yq"
223+
| "python3"
224+
| "python"
225+
| "node"
226+
| "ruby"
227+
| "cargo"
228+
| "rustc"
229+
| "git"
230+
| "gh"
231+
) && !command.contains("-i ")
232+
&& !command.contains("--in-place")
233+
&& !command.contains(" > ")
234+
&& !command.contains(" >> ")
235+
}
236+
237+
#[cfg(test)]
238+
mod tests {
239+
use super::*;
240+
241+
fn make_enforcer(mode: PermissionMode) -> PermissionEnforcer {
242+
let policy = PermissionPolicy::new(mode);
243+
PermissionEnforcer::new(policy)
244+
}
245+
246+
#[test]
247+
fn allow_mode_permits_everything() {
248+
let enforcer = make_enforcer(PermissionMode::Allow);
249+
assert!(enforcer.is_allowed("bash", ""));
250+
assert!(enforcer.is_allowed("write_file", ""));
251+
assert!(enforcer.is_allowed("edit_file", ""));
252+
assert_eq!(
253+
enforcer.check_file_write("/outside/path", "/workspace"),
254+
EnforcementResult::Allowed
255+
);
256+
assert_eq!(enforcer.check_bash("rm -rf /"), EnforcementResult::Allowed);
257+
}
258+
259+
#[test]
260+
fn read_only_denies_writes() {
261+
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
262+
.with_tool_requirement("read_file", PermissionMode::ReadOnly)
263+
.with_tool_requirement("grep_search", PermissionMode::ReadOnly)
264+
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
265+
266+
let enforcer = PermissionEnforcer::new(policy);
267+
assert!(enforcer.is_allowed("read_file", ""));
268+
assert!(enforcer.is_allowed("grep_search", ""));
269+
270+
// write_file requires WorkspaceWrite but we're in ReadOnly
271+
let result = enforcer.check("write_file", "");
272+
assert!(matches!(result, EnforcementResult::Denied { .. }));
273+
274+
let result = enforcer.check_file_write("/workspace/file.rs", "/workspace");
275+
assert!(matches!(result, EnforcementResult::Denied { .. }));
276+
}
277+
278+
#[test]
279+
fn read_only_allows_read_commands() {
280+
let enforcer = make_enforcer(PermissionMode::ReadOnly);
281+
assert_eq!(
282+
enforcer.check_bash("cat src/main.rs"),
283+
EnforcementResult::Allowed
284+
);
285+
assert_eq!(
286+
enforcer.check_bash("grep -r 'pattern' ."),
287+
EnforcementResult::Allowed
288+
);
289+
assert_eq!(enforcer.check_bash("ls -la"), EnforcementResult::Allowed);
290+
}
291+
292+
#[test]
293+
fn read_only_denies_write_commands() {
294+
let enforcer = make_enforcer(PermissionMode::ReadOnly);
295+
let result = enforcer.check_bash("rm file.txt");
296+
assert!(matches!(result, EnforcementResult::Denied { .. }));
297+
}
298+
299+
#[test]
300+
fn workspace_write_allows_within_workspace() {
301+
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
302+
let result = enforcer.check_file_write("/workspace/src/main.rs", "/workspace");
303+
assert_eq!(result, EnforcementResult::Allowed);
304+
}
305+
306+
#[test]
307+
fn workspace_write_denies_outside_workspace() {
308+
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
309+
let result = enforcer.check_file_write("/etc/passwd", "/workspace");
310+
assert!(matches!(result, EnforcementResult::Denied { .. }));
311+
}
312+
313+
#[test]
314+
fn prompt_mode_denies_without_prompter() {
315+
let enforcer = make_enforcer(PermissionMode::Prompt);
316+
let result = enforcer.check_bash("echo test");
317+
assert!(matches!(result, EnforcementResult::Denied { .. }));
318+
319+
let result = enforcer.check_file_write("/workspace/file.rs", "/workspace");
320+
assert!(matches!(result, EnforcementResult::Denied { .. }));
321+
}
322+
323+
#[test]
324+
fn workspace_boundary_check() {
325+
assert!(is_within_workspace("/workspace/src/main.rs", "/workspace"));
326+
assert!(is_within_workspace("/workspace", "/workspace"));
327+
assert!(!is_within_workspace("/etc/passwd", "/workspace"));
328+
assert!(!is_within_workspace("/workspacex/hack", "/workspace"));
329+
}
330+
331+
#[test]
332+
fn read_only_command_heuristic() {
333+
assert!(is_read_only_command("cat file.txt"));
334+
assert!(is_read_only_command("grep pattern file"));
335+
assert!(is_read_only_command("git log --oneline"));
336+
assert!(!is_read_only_command("rm file.txt"));
337+
assert!(!is_read_only_command("echo test > file.txt"));
338+
assert!(!is_read_only_command("sed -i 's/a/b/' file"));
339+
}
340+
}

rust/crates/tools/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use runtime::{
1414
edit_file, execute_bash, glob_search, grep_search, load_system_prompt,
1515
lsp_client::LspRegistry,
1616
mcp_tool_bridge::McpToolRegistry,
17+
permission_enforcer::{EnforcementResult, PermissionEnforcer},
1718
read_file,
1819
task_registry::TaskRegistry,
1920
team_cron_registry::{CronRegistry, TeamRegistry},
@@ -872,6 +873,21 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
872873
]
873874
}
874875

876+
/// Check permission before executing a tool. Returns Err with denial reason if blocked.
877+
pub fn enforce_permission_check(
878+
enforcer: &PermissionEnforcer,
879+
tool_name: &str,
880+
input: &Value,
881+
) -> Result<(), String> {
882+
let input_str = serde_json::to_string(input).unwrap_or_default();
883+
let result = enforcer.check(tool_name, &input_str);
884+
885+
match result {
886+
EnforcementResult::Allowed => Ok(()),
887+
EnforcementResult::Denied { reason, .. } => Err(reason),
888+
}
889+
}
890+
875891
pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
876892
match name {
877893
"bash" => from_value::<BashCommandInput>(input).and_then(run_bash),

0 commit comments

Comments
 (0)