use std::path::{Component, Path, PathBuf};
use car_policy::{InspectionResult, Inspector, InspectorChain};
use serde_json::Value;
pub fn coder_inspector_chain(worktree: &Path) -> InspectorChain {
InspectorChain::new()
.with(Box::new(DenyGitRemoteMutation))
.with(Box::new(DenyHistoryRewrite))
.with(Box::new(DenyPrivilegeEscalation))
.with(Box::new(DenyCredentialAccess))
.with(Box::new(DenyDestructiveOutsideWorktree {
worktree: worktree.to_path_buf(),
}))
.with(Box::new(DenyPathEscape {
worktree: worktree.to_path_buf(),
}))
}
pub(crate) fn stays_under(root: &Path, candidate: &str) -> bool {
let p = Path::new(candidate);
let joined = if p.is_absolute() {
p.to_path_buf()
} else {
root.join(p)
};
let mut stack: Vec<Component> = Vec::new();
for c in joined.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
if stack.pop().is_none() {
return false;
}
}
other => stack.push(other),
}
}
let normalized: PathBuf = stack.iter().collect();
path_starts_with(&normalized, root)
}
#[cfg(not(windows))]
fn path_starts_with(path: &Path, base: &Path) -> bool {
path.starts_with(base)
}
#[cfg(windows)]
fn path_starts_with(path: &Path, base: &Path) -> bool {
fn key(p: &Path) -> String {
let s = p.to_string_lossy().into_owned();
let s = if let Some(r) = s.strip_prefix(r"\\?\UNC\") {
format!(r"\\{r}")
} else if let Some(r) = s.strip_prefix(r"\\?\") {
r.to_string()
} else {
s
};
s.replace('/', "\\").to_ascii_lowercase()
}
let base_key = key(base);
let base_trim = base_key.trim_end_matches('\\');
let path_key = key(path);
path_key == base_trim || path_key.starts_with(&format!("{base_trim}\\"))
}
fn is_abs_or_traversal(arg: &str) -> bool {
arg.starts_with('/')
|| arg.starts_with('\\')
|| arg.contains("..")
|| Path::new(arg).is_absolute()
}
fn is_windows_switch(arg: &str) -> bool {
#[cfg(not(windows))]
{
let _ = arg;
false
}
#[cfg(windows)]
{
arg.strip_prefix('/')
.map(|rest| {
(1..=2).contains(&rest.len()) && rest.chars().all(|c| c.is_ascii_alphanumeric())
})
.unwrap_or(false)
}
}
fn segments(command: &str) -> Vec<Vec<String>> {
command
.replace("&&", "\n")
.replace("||", "\n")
.replace([';', ';', '|'], "\n")
.lines()
.map(|seg| {
seg.split_whitespace()
.map(|t| t.trim_matches(|c| c == '"' || c == '\'').to_string())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
})
.filter(|toks: &Vec<String>| !toks.is_empty())
.collect()
}
fn verb(tokens: &[String]) -> Option<&str> {
tokens.iter().map(String::as_str).find(|t| !t.contains('='))
}
fn shell_command(tool: &str, params: &Value) -> Option<String> {
if tool != "shell" {
return None;
}
params
.get("command")
.and_then(Value::as_str)
.map(str::to_string)
}
struct DenyGitRemoteMutation;
impl Inspector for DenyGitRemoteMutation {
fn name(&self) -> &'static str {
"coder.deny_git_remote_mutation"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
let is_git = verb(&seg) == Some("git");
if !is_git {
continue;
}
if seg.iter().any(|t| t == "push") {
return InspectionResult::Deny(
"git push is not allowed from a coder session — results are delivered \
via the approved local branch"
.into(),
);
}
if seg.iter().any(|t| t == "remote")
&& seg
.iter()
.any(|t| t == "add" || t == "set-url" || t == "remove")
{
return InspectionResult::Deny("mutating git remotes is not allowed".into());
}
}
InspectionResult::Allow
}
}
struct DenyHistoryRewrite;
impl Inspector for DenyHistoryRewrite {
fn name(&self) -> &'static str {
"coder.deny_history_rewrite"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
if verb(&seg) != Some("git") {
continue;
}
if seg.iter().any(|t| t == "rebase" || t == "filter-branch") {
return InspectionResult::Deny("git history rewrite is not allowed".into());
}
if seg.iter().any(|t| t == "reset") && seg.iter().any(|t| t == "--hard") {
return InspectionResult::Deny("git reset --hard is not allowed".into());
}
if seg.iter().any(|t| t == "worktree") && seg.iter().any(|t| t == "remove") {
return InspectionResult::Deny(
"removing worktrees is the runtime's job, not the agent's".into(),
);
}
}
InspectionResult::Allow
}
}
struct DenyPrivilegeEscalation;
const PRIVILEGE_VERBS: &[&str] = &[
"sudo",
"doas",
"su",
"launchctl",
"systemctl", "runas",
"sc",
"psexec",
];
impl Inspector for DenyPrivilegeEscalation {
fn name(&self) -> &'static str {
"coder.deny_privilege_escalation"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
if let Some(v) = verb(&seg) {
if PRIVILEGE_VERBS.contains(&v.to_ascii_lowercase().as_str()) {
return InspectionResult::Deny(format!(
"'{v}' is not allowed in a coder session"
));
}
}
}
InspectionResult::Allow
}
}
struct DenyCredentialAccess;
const CREDENTIAL_PATH_MARKERS: [&str; 6] = [
"/.ssh",
"/.aws",
"/.gnupg",
"/.kube",
"/.car/secrets",
"/.netrc",
];
impl Inspector for DenyCredentialAccess {
fn name(&self) -> &'static str {
"coder.deny_credential_access"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let haystacks: Vec<String> = if let Some(cmd) = shell_command(tool, params) {
if cmd.contains("find-generic-password") || cmd.contains("find-internet-password") {
return InspectionResult::Deny("keychain access is not allowed".into());
}
let cmd_lower = cmd.to_ascii_lowercase();
if cmd_lower.contains("cmdkey") || cmd_lower.contains("vaultcmd") {
return InspectionResult::Deny(
"Windows Credential Manager access is not allowed".into(),
);
}
vec![cmd]
} else if matches!(
tool,
"read_file" | "write_file" | "edit_file" | "grep_files"
) {
params
.get("path")
.and_then(Value::as_str)
.map(|p| vec![p.to_string()])
.unwrap_or_default()
} else {
return InspectionResult::Allow;
};
for hay in &haystacks {
let hay = hay.replace('\\', "/");
let hay = hay
.replace("~/", "/HOME/.")
.replace("$HOME/", "/HOME/.")
.replace("%USERPROFILE%/", "/HOME/.")
.replace("%HOMEPATH%/", "/HOME/.");
let hay = hay.replace("/HOME/..", "/."); for marker in CREDENTIAL_PATH_MARKERS {
if hay.contains(marker) {
return InspectionResult::Deny(format!(
"access to credential path matching '{marker}' is not allowed"
));
}
}
}
InspectionResult::Allow
}
}
struct DenyDestructiveOutsideWorktree {
worktree: PathBuf,
}
const DESTRUCTIVE_VERBS: &[&str] = &[
"rm", "rmdir", "mv", "cp", "chmod", "chown", "truncate", "dd", "del", "erase", "rd", "move", "copy", "format", "ren", "rename",
];
impl Inspector for DenyDestructiveOutsideWorktree {
fn name(&self) -> &'static str {
"coder.deny_destructive_outside_worktree"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
let Some(v) = verb(&seg) else { continue };
let v_lower = v.to_ascii_lowercase();
if !DESTRUCTIVE_VERBS.contains(&v_lower.as_str()) {
continue;
}
for arg in seg
.iter()
.skip(1)
.filter(|a| !a.starts_with('-') && !is_windows_switch(a))
{
if arg.starts_with('~') {
return InspectionResult::Deny(format!(
"'{v}' on a home-relative path ('{arg}') is not allowed"
));
}
if is_abs_or_traversal(arg) && !stays_under(&self.worktree, arg) {
return InspectionResult::Deny(format!(
"'{v}' outside the worktree ('{arg}') is not allowed"
));
}
}
}
InspectionResult::Allow
}
}
struct DenyPathEscape {
worktree: PathBuf,
}
impl Inspector for DenyPathEscape {
fn name(&self) -> &'static str {
"coder.deny_path_escape"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
if !matches!(tool, "write_file" | "edit_file") {
return InspectionResult::Allow;
}
let Some(path) = params.get("path").and_then(Value::as_str) else {
return InspectionResult::Allow; };
if stays_under(&self.worktree, path) {
InspectionResult::Allow
} else {
InspectionResult::Deny(format!("write to '{path}' resolves outside the worktree"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn chain() -> InspectorChain {
coder_inspector_chain(Path::new("/wt"))
}
fn denied(tool: &str, params: Value) -> bool {
chain().check(tool, ¶ms).is_some()
}
fn sh(cmd: &str) -> Value {
json!({ "command": cmd })
}
#[test]
fn git_push_and_remote_mutation_denied() {
assert!(denied("shell", sh("git push origin main")));
assert!(denied("shell", sh("cargo test && git push --force")));
assert!(denied("shell", sh("git remote add evil https://x")));
assert!(denied("shell", sh("git remote set-url origin https://x")));
assert!(!denied("shell", sh("git remote -v")));
assert!(!denied("shell", sh("git commit -m 'x'")));
assert!(!denied("shell", sh("git status && git diff")));
assert!(!denied("shell", sh("echo push")));
}
#[test]
fn history_rewrite_denied() {
assert!(denied("shell", sh("git rebase -i HEAD~3")));
assert!(denied("shell", sh("git reset --hard HEAD~1")));
assert!(denied("shell", sh("git filter-branch --all")));
assert!(denied("shell", sh("git worktree remove /wt")));
assert!(!denied("shell", sh("git reset HEAD file.txt"))); }
#[test]
fn privilege_escalation_denied() {
assert!(denied("shell", sh("sudo rm -rf /tmp/x")));
assert!(denied("shell", sh("doas pkg_add x")));
assert!(denied("shell", sh("FOO=1 sudo make install")));
assert!(denied("shell", sh("launchctl unload foo")));
assert!(!denied("shell", sh("echo sudo"))); }
#[test]
fn credential_access_denied_for_shell_and_file_tools() {
assert!(denied("shell", sh("cat ~/.ssh/id_rsa")));
assert!(denied("shell", sh("cat $HOME/.aws/credentials")));
assert!(denied("shell", sh("security find-generic-password -s x")));
assert!(denied("read_file", json!({"path": "/Users/u/.ssh/id_rsa"})));
assert!(denied("read_file", json!({"path": "~/.netrc"})));
assert!(!denied("read_file", json!({"path": "src/main.rs"})));
}
#[test]
fn destructive_ops_scoped_to_worktree() {
assert!(denied("shell", sh("rm -rf /etc")));
assert!(denied("shell", sh("rm -rf ../other-checkout")));
assert!(denied("shell", sh("mv target ~/elsewhere")));
assert!(denied("shell", sh("chmod 777 /usr/local/bin/x")));
assert!(!denied("shell", sh("rm -rf target/debug")));
assert!(!denied("shell", sh("rm /wt/scratch.txt")));
assert!(!denied("shell", sh("cp a.txt b.txt")));
}
#[test]
fn write_path_escape_denied_but_reads_allowed() {
assert!(denied(
"write_file",
json!({"path": "/etc/hosts", "content": "x"})
));
assert!(denied("edit_file", json!({"path": "../outside.txt"})));
assert!(!denied(
"write_file",
json!({"path": "src/new.rs", "content": "x"})
));
assert!(!denied(
"write_file",
json!({"path": "/wt/src/new.rs", "content": "x"})
));
assert!(!denied(
"read_file",
json!({"path": "/usr/include/stdio.h"})
));
}
#[test]
fn stays_under_is_lexical_and_strict() {
let root = Path::new("/wt");
assert!(stays_under(root, "src/x.rs"));
assert!(stays_under(root, "a/../b.txt"));
assert!(stays_under(root, "/wt/deep/file"));
assert!(!stays_under(root, "../escape"));
assert!(!stays_under(root, "a/../../escape"));
assert!(!stays_under(root, "/etc/passwd"));
assert!(!stays_under(root, "/wtevil/file")); }
#[cfg(windows)]
#[test]
fn windows_destructive_and_privilege_denied() {
let chain = coder_inspector_chain(Path::new(r"C:\wt"));
let denied = |cmd: &str| chain.check("shell", &sh(cmd)).is_some();
assert!(denied(r"del C:\Windows\System32\drivers\etc\hosts"));
assert!(denied(r"rd /s /q C:\Windows"));
assert!(denied(r"del /q C:\Users\victim\file")); assert!(denied(r"move C:\wt\keep.txt C:\Users\public\stolen.txt"));
assert!(denied("runas /user:Administrator cmd"));
assert!(denied("sc stop windefend"));
assert!(!denied(r"del C:\wt\target\debug\app.exe"));
assert!(!denied(r"del build\out.txt"));
assert!(!denied("dir")); }
#[cfg(windows)]
#[test]
fn windows_credential_access_denied() {
let chain = coder_inspector_chain(Path::new(r"C:\wt"));
assert!(chain
.check("shell", &sh(r"type %USERPROFILE%\.ssh\id_rsa"))
.is_some());
assert!(chain.check("shell", &sh("cmdkey /list")).is_some());
assert!(chain
.check(
"read_file",
&json!({"path": r"C:\Users\u\.aws\credentials"})
)
.is_some());
assert!(chain
.check("read_file", &json!({"path": r"C:\wt\src\main.rs"}))
.is_none());
}
#[cfg(windows)]
#[test]
fn stays_under_handles_verbatim_prefix_and_case() {
let root = Path::new(r"\\?\C:\wt");
assert!(stays_under(root, r"C:\WT\src\main.rs"));
assert!(stays_under(root, r"c:\wt\src\main.rs"));
assert!(!stays_under(root, r"C:\other\x"));
assert!(!stays_under(root, r"C:\wtevil\x")); }
}