Skip to content

Commit 5b15197

Browse files
authored
Merge pull request #3209 from Sam0urr/harden-permission-enforcer
Harden permission enforcement against sandbox bypasses
2 parents 61e8ad9 + e8c8ef1 commit 5b15197

2 files changed

Lines changed: 207 additions & 20 deletions

File tree

rust/crates/runtime/src/permission_enforcer.rs

Lines changed: 173 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -173,33 +173,119 @@ impl PermissionEnforcer {
173173
}
174174
}
175175

176-
/// Simple workspace boundary check via string prefix.
176+
/// Workspace boundary check.
177+
///
178+
/// Resolves `.` and `..` components lexically *before* comparing against the
179+
/// workspace root, so that traversal sequences like `/workspace/../../etc`
180+
/// cannot escape the sandbox via a naive string prefix match. Normalization is
181+
/// lexical (it does not touch the filesystem) because the target path may not
182+
/// exist yet on a write, and we must not depend on CWD.
177183
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
178-
let normalized = if path.starts_with('/') {
184+
let combined = if path.starts_with('/') {
179185
path.to_owned()
180186
} else {
181187
format!("{workspace_root}/{path}")
182188
};
183189

184-
let root = if workspace_root.ends_with('/') {
185-
workspace_root.to_owned()
190+
let normalized = lexically_normalize(&combined);
191+
let root = lexically_normalize(workspace_root);
192+
let root_with_slash = if root.ends_with('/') {
193+
root.clone()
186194
} else {
187-
format!("{workspace_root}/")
195+
format!("{root}/")
188196
};
189197

190-
normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/')
198+
normalized == root || normalized.starts_with(&root_with_slash)
199+
}
200+
201+
/// Collapse `.` and `..` segments without consulting the filesystem.
202+
/// `..` that would climb above an absolute root is clamped at `/`, so the
203+
/// result can never be a prefix-match for a deeper workspace root.
204+
fn lexically_normalize(path: &str) -> String {
205+
let is_absolute = path.starts_with('/');
206+
let mut stack: Vec<&str> = Vec::new();
207+
for component in path.split('/') {
208+
match component {
209+
"" | "." => {}
210+
".." => {
211+
stack.pop();
212+
}
213+
other => stack.push(other),
214+
}
215+
}
216+
let joined = stack.join("/");
217+
if is_absolute {
218+
format!("/{joined}")
219+
} else {
220+
joined
221+
}
191222
}
192223

193224
/// Conservative heuristic: is this bash command read-only?
225+
///
226+
/// Hardening notes:
227+
/// - Any shell metacharacter that could chain, substitute, pipe, or redirect
228+
/// into a state-changing command rejects the whole line. This blocks
229+
/// `cat x; rm -rf y`, `cat x | sh`, `$(...)`, backticks, redirects, and
230+
/// subshells regardless of the leading token.
231+
/// - Language interpreters (`python`, `node`, `ruby`) and build drivers
232+
/// (`cargo`, `rustc`) are NOT read-only: they execute arbitrary code, so they
233+
/// are excluded from the allow-list.
234+
/// - `git` is allowed only for a known set of non-mutating subcommands.
235+
/// - `find` is rejected when it carries an action that can execute or delete.
236+
///
237+
/// Residual known gaps (documented, not yet closed): `sed`'s `w`/`e` script
238+
/// commands and `awk`'s `system()` can still mutate — these require quoting or
239+
/// metacharacters that the checks above usually catch, but a dedicated parser
240+
/// would be more robust. Tracked as follow-up.
194241
fn is_read_only_command(command: &str) -> bool {
195-
let first_token = command
196-
.split_whitespace()
242+
// Shell metacharacters that enable command chaining, substitution,
243+
// piping, redirection, or subshells. Presence of any of these means we
244+
// cannot reason about the command from its leading token alone.
245+
const SHELL_METACHARS: &[char] =
246+
&[';', '|', '&', '$', '`', '>', '<', '(', ')', '{', '}', '\n'];
247+
if command.contains(SHELL_METACHARS) {
248+
return false;
249+
}
250+
251+
let mut tokens = command.split_whitespace();
252+
let first_token = tokens
197253
.next()
198254
.unwrap_or("")
199255
.rsplit('/')
200256
.next()
201257
.unwrap_or("");
202258

259+
// `git` is only read-only for a curated set of subcommands.
260+
if first_token == "git" {
261+
let subcommand = tokens.next().unwrap_or("");
262+
return matches!(
263+
subcommand,
264+
"status"
265+
| "log"
266+
| "diff"
267+
| "show"
268+
| "branch"
269+
| "rev-parse"
270+
| "ls-files"
271+
| "blame"
272+
| "describe"
273+
| "tag"
274+
| "remote"
275+
);
276+
}
277+
278+
// `find` can execute or delete via actions; reject those forms.
279+
if first_token == "find"
280+
&& (command.contains("-exec")
281+
|| command.contains("-execdir")
282+
|| command.contains("-delete")
283+
|| command.contains("-ok")
284+
|| command.contains("-fprintf"))
285+
{
286+
return false;
287+
}
288+
203289
matches!(
204290
first_token,
205291
"cat"
@@ -237,8 +323,6 @@ fn is_read_only_command(command: &str) -> bool {
237323
| "tr"
238324
| "cut"
239325
| "paste"
240-
| "tee"
241-
| "xargs"
242326
| "test"
243327
| "true"
244328
| "false"
@@ -257,18 +341,8 @@ fn is_read_only_command(command: &str) -> bool {
257341
| "tree"
258342
| "jq"
259343
| "yq"
260-
| "python3"
261-
| "python"
262-
| "node"
263-
| "ruby"
264-
| "cargo"
265-
| "rustc"
266-
| "git"
267-
| "gh"
268344
) && !command.contains("-i ")
269345
&& !command.contains("--in-place")
270-
&& !command.contains(" > ")
271-
&& !command.contains(" >> ")
272346
}
273347

274348
#[cfg(test)]
@@ -375,6 +449,85 @@ mod tests {
375449
assert!(!is_read_only_command("sed -i 's/a/b/' file"));
376450
}
377451

452+
// --- Hardening regression tests (#2: read-only bypasses) ---
453+
454+
#[test]
455+
fn read_only_rejects_command_chaining() {
456+
// A leading read-only token must not launder a trailing destructive one.
457+
assert!(!is_read_only_command("cat foo; rm -rf bar"));
458+
assert!(!is_read_only_command("cat foo && rm -rf bar"));
459+
assert!(!is_read_only_command("ls || rm bar"));
460+
assert!(!is_read_only_command("cat foo | sh"));
461+
assert!(!is_read_only_command("echo `rm bar`"));
462+
assert!(!is_read_only_command("echo $(rm bar)"));
463+
assert!(!is_read_only_command("echo x>file")); // redirect without spaces
464+
}
465+
466+
#[test]
467+
fn read_only_rejects_interpreters_and_build_drivers() {
468+
// These execute arbitrary code and are no longer read-only.
469+
assert!(!is_read_only_command(
470+
"python3 -c \"import os; os.system('rm -rf .')\""
471+
));
472+
assert!(!is_read_only_command("python script.py"));
473+
assert!(!is_read_only_command("node app.js"));
474+
assert!(!is_read_only_command("ruby x.rb"));
475+
assert!(!is_read_only_command("cargo run"));
476+
assert!(!is_read_only_command("rustc evil.rs"));
477+
}
478+
479+
#[test]
480+
fn read_only_gates_git_subcommands() {
481+
// Read-only git subcommands remain allowed...
482+
assert!(is_read_only_command("git status"));
483+
assert!(is_read_only_command("git diff HEAD~1"));
484+
assert!(is_read_only_command("git show abc123"));
485+
// ...but mutating/exfiltrating ones are rejected.
486+
assert!(!is_read_only_command("git commit -m x"));
487+
assert!(!is_read_only_command("git push origin main"));
488+
assert!(!is_read_only_command("git reset --hard"));
489+
assert!(!is_read_only_command("git clean -fd"));
490+
assert!(!is_read_only_command("git config user.email a@b.c"));
491+
}
492+
493+
#[test]
494+
fn read_only_rejects_find_actions() {
495+
assert!(is_read_only_command("find . -name Cargo.toml"));
496+
assert!(!is_read_only_command("find . -delete"));
497+
// -exec uses braces/semicolon which also trip the metachar guard,
498+
// but the explicit action check is the primary defense.
499+
assert!(!is_read_only_command("find . -execdir rm rf"));
500+
}
501+
502+
// --- Hardening regression tests (#1: workspace path traversal) ---
503+
504+
#[test]
505+
fn workspace_rejects_parent_traversal() {
506+
assert!(!is_within_workspace("/workspace/../etc/passwd", "/workspace"));
507+
assert!(!is_within_workspace(
508+
"/workspace/../../etc/crontab",
509+
"/workspace"
510+
));
511+
assert!(!is_within_workspace("../etc/passwd", "/workspace"));
512+
assert!(!is_within_workspace(
513+
"/workspace/sub/../../outside",
514+
"/workspace"
515+
));
516+
// Legitimate paths still resolve inside.
517+
assert!(is_within_workspace("/workspace/./src/main.rs", "/workspace"));
518+
assert!(is_within_workspace(
519+
"/workspace/src/../src/main.rs",
520+
"/workspace"
521+
));
522+
}
523+
524+
#[test]
525+
fn workspace_write_denies_traversal_escape() {
526+
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
527+
let result = enforcer.check_file_write("/workspace/../../etc/crontab", "/workspace");
528+
assert!(matches!(result, EnforcementResult::Denied { .. }));
529+
}
530+
378531
#[test]
379532
fn active_mode_returns_policy_mode() {
380533
// given

rust/crates/tools/src/lib.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2701,6 +2701,20 @@ fn is_within_workspace(path: &str) -> bool {
27012701

27022702
let path = PathBuf::from(trimmed);
27032703

2704+
// Reject any parent-directory traversal. Callers never need `..` to refer
2705+
// to files inside the workspace, and `..` defeats both checks below: the
2706+
// relative branch only inspects the leading component, and the absolute
2707+
// branch's `canonicalize()` silently falls back to the literal `..` path
2708+
// when the target does not exist yet (e.g. a file about to be created).
2709+
// Returning false here is the safe direction: it classifies the command as
2710+
// requiring full-access permission rather than workspace-write.
2711+
if path
2712+
.components()
2713+
.any(|component| matches!(component, std::path::Component::ParentDir))
2714+
{
2715+
return false;
2716+
}
2717+
27042718
// If path is absolute, check if it starts with CWD
27052719
if path.is_absolute() {
27062720
if let Ok(cwd) = std::env::current_dir() {
@@ -2718,6 +2732,26 @@ fn run_powershell(input: PowerShellInput) -> Result<String, String> {
27182732
to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?)
27192733
}
27202734

2735+
#[cfg(test)]
2736+
mod workspace_traversal_guard_tests {
2737+
use super::is_within_workspace;
2738+
2739+
#[test]
2740+
fn rejects_parent_traversal_components() {
2741+
// Leading and embedded `..` must both be rejected (was previously a hole
2742+
// because only the leading component was inspected).
2743+
assert!(!is_within_workspace("../secrets"));
2744+
assert!(!is_within_workspace("src/../../etc/passwd"));
2745+
assert!(!is_within_workspace("a/b/../../../etc/crontab"));
2746+
}
2747+
2748+
#[test]
2749+
fn allows_plain_relative_paths() {
2750+
assert!(is_within_workspace("src/main.rs"));
2751+
assert!(is_within_workspace("Cargo.toml"));
2752+
}
2753+
}
2754+
27212755
fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
27222756
serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
27232757
}

0 commit comments

Comments
 (0)