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();
normalized.starts_with(root)
}
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; 5] = ["sudo", "doas", "su", "launchctl", "systemctl"];
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) {
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());
}
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("~/", "/HOME/.").replace("$HOME/", "/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; 8] = ["rm", "rmdir", "mv", "cp", "chmod", "chown", "truncate", "dd"];
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 };
if !DESTRUCTIVE_VERBS.contains(&v) {
continue;
}
for arg in seg.iter().skip(1).filter(|a| !a.starts_with('-')) {
if arg.starts_with('~') {
return InspectionResult::Deny(format!(
"'{v}' on a home-relative path ('{arg}') is not allowed"
));
}
if (arg.starts_with('/') || arg.contains("..")) && !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")); }
}