g1t/crates/runner/src/guard.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 1 | //! Guardrails inside the sandbox: the command rules the agent's harness |
| 2 | //! enforces, its cost and time caps, and trusting the certificate the | |
| 3 | //! sandbox's HTTPS is re-signed with when its network is restricted. | |
| 4 | //! | |
| 5 | //! The runner service passes the run's guardrails as `GUARDRAILS`. Before | |
| 6 | //! Claude Code starts, `install` writes them where the harness reads them: | |
| 7 | //! | |
| 8 | //! - Claude Code's managed settings (`/etc/claude-code/managed-settings.json`, | |
| 9 | //! root-owned, which no other settings file can override) get a | |
| 10 | //! `PreToolUse` hook that runs this program in `MODE=guard` before every | |
| 11 | //! tool call, and `permissions.deny` rules as a second layer. | |
| 12 | //! - With the `sudo` rule on, the sandbox then gives up root, so the agent | |
| 13 | //! cannot change either. Where that cannot be done (no sudo), the same | |
| 14 | //! settings are passed with `--settings` instead. | |
| 15 | //! | |
| 16 | //! The hook (`hook_main`) decides with `decide`: a refused call is not | |
| 17 | //! made, Claude Code tells the agent why, and the refusal is appended to | |
| 18 | //! `DENIED_LOG`, which the harness reads and reports as a step of the run. | |
| 19 | //! | |
| 20 | //! Matching shell commands against rules is a guard against an agent's | |
| 21 | //! mistakes, not a sandbox: a command can always be written in a way no | |
| 22 | //! rule foresees. The network list, the credentials a sandbox holds, and | |
| 23 | //! branch protection on g1t's side are the hard boundaries. | |
| 24 | ||
| 25 | use std::collections::BTreeMap; | |
| 26 | use std::fmt; | |
| 27 | use std::io::{Read, Write}; | |
| 28 | use std::path::Path; | |
| 29 | use std::process::{Command, Stdio}; | |
| 30 | ||
| 31 | use serde::Deserialize; | |
| 32 | use serde_json::{Value, json}; | |
| 33 | ||
| 34 | /// Where the guardrails are kept for the hook: root-owned where it can be. | |
| 35 | const POLICY_FILES: [&str; 2] = ["/etc/g1t/guard.json", "/work/.g1t/guard.json"]; | |
| 36 | const MANAGED_SETTINGS: &str = "/etc/claude-code/managed-settings.json"; | |
| 37 | /// Settings passed with `--settings` when the managed file cannot be written. | |
| 38 | pub const FALLBACK_SETTINGS: &str = "/work/.g1t/settings.json"; | |
| 39 | /// Refusals, one per line, for the harness to report. | |
| 40 | pub const DENIED_LOG: &str = "/work/.g1t/denied.log"; | |
| 41 | /// The certificate the sandbox's HTTPS is re-signed with, when it is guarded. | |
| 42 | const EGRESS_CA: &str = "/etc/cloudflare/certs/cloudflare-containers-ca.crt"; | |
| 43 | const HOOK_COMMAND: &str = "MODE=guard /usr/local/bin/g1t-runner"; | |
| 44 | ||
| 45 | /// The run's guardrails, as the runner service passes them. | |
| 46 | #[derive(Clone, Debug, Default, Deserialize)] | |
| 47 | #[serde(rename_all = "camelCase", default)] | |
| 48 | pub struct Policy { | |
| 49 | pub rules: BTreeMap<String, bool>, | |
| 50 | pub deny: Vec<String>, | |
| 51 | pub budget_usd: Option<f64>, | |
| 52 | /// This run's time cap, in minutes. | |
| 53 | pub minutes: Option<u32>, | |
| 54 | pub restrict_network: bool, | |
| 55 | /// The branch everything lands on, when the runner knows it. | |
| 56 | pub default_branch: Option<String>, | |
| 57 | } | |
| 58 | ||
| 59 | impl Policy { | |
| 60 | pub fn from_env() -> Option<Policy> { | |
| 61 | serde_json::from_str(&std::env::var("GUARDRAILS").ok()?).ok() | |
| 62 | } | |
| 63 | ||
| 64 | fn on(&self, rule: &str) -> bool { | |
| 65 | self.rules.get(rule).copied().unwrap_or(false) | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | /// Why g1t stopped a run by itself: it reached a cap. | |
| 70 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 71 | pub enum Halted { | |
| 72 | Budget, | |
| 73 | Time, | |
| 74 | } | |
| 75 | ||
| 76 | impl Halted { | |
| 77 | pub fn reason(self) -> &'static str { | |
| 78 | match self { | |
| 79 | Halted::Budget => "budget", | |
| 80 | Halted::Time => "time", | |
| 81 | } | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | impl fmt::Display for Halted { | |
| 86 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 87 | match self { | |
| 88 | Halted::Budget => write!(f, "the run reached its cost cap"), | |
| 89 | Halted::Time => write!(f, "the run reached its time cap"), | |
| 90 | } | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | impl std::error::Error for Halted {} | |
| 95 | ||
| 96 | /// Whether a run failed because it reached a cap, rather than failing. | |
| 97 | pub fn is_halt(error: &anyhow::Error) -> bool { | |
| 98 | error.downcast_ref::<Halted>().is_some() | |
| 99 | } | |
| 100 | ||
| 101 | /// Runs a command as root without asking. False where that is not allowed. | |
| 102 | fn sudo(args: &[&str], input: Option<&str>) -> bool { | |
| 103 | let child = Command::new("sudo") | |
| 104 | .arg("-n") | |
| 105 | .args(args) | |
| 106 | .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) | |
| 107 | .stdout(Stdio::null()) | |
| 108 | .stderr(Stdio::null()) | |
| 109 | .spawn(); | |
| 110 | let Ok(mut child) = child else { | |
| 111 | return false; | |
| 112 | }; | |
| 113 | if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) { | |
| 114 | let _ = stdin.write_all(input.as_bytes()); | |
| 115 | } | |
| 116 | child.wait().is_ok_and(|status| status.success()) | |
| 117 | } | |
| 118 | ||
| 119 | /// Writes `contents` to a root-owned file that others can read. | |
| 120 | fn write_as_root(path: &str, contents: &str) -> bool { | |
| 121 | let dir = Path::new(path).parent().and_then(Path::to_str).unwrap_or("/"); | |
| 122 | sudo(&["mkdir", "-p", dir], None) | |
| 123 | && sudo(&["tee", path], Some(contents)) | |
| 124 | && sudo(&["chmod", "0644", path], None) | |
| 125 | } | |
| 126 | ||
| 127 | /// Trusts the certificate a guarded sandbox's HTTPS is re-signed with, so | |
| 128 | /// that git, package managers and this program can reach allowed hosts. | |
| 129 | /// Does nothing in a sandbox whose network is open. Called first thing, | |
| 130 | /// before any request is made. | |
| 131 | pub fn trust_egress_ca() { | |
| 132 | const TRUSTED: &str = "/usr/local/share/ca-certificates/cloudflare-containers-ca.crt"; | |
| 133 | // Once per sandbox: the hooks run this program after every tool call. | |
| 134 | if !Path::new(EGRESS_CA).exists() || Path::new(TRUSTED).exists() { | |
| 135 | return; | |
| 136 | } | |
| 137 | let trusted = sudo(&["cp", EGRESS_CA, TRUSTED], None) && sudo(&["update-ca-certificates"], None); | |
| 138 | if !trusted { | |
| 139 | eprintln!("g1t-runner: could not trust the sandbox's egress certificate; HTTPS will fail"); | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | /// The permission rules that back up each built-in rule. The hook is what | |
| 144 | /// enforces them; these are a second layer the harness applies itself. | |
| 145 | fn rule_patterns(rule: &str) -> &'static [&'static str] { | |
| 146 | match rule { | |
| 147 | "force_push" => &[ | |
| 148 | "Bash(git push --force:*)", | |
| 149 | "Bash(git push -f:*)", | |
| 150 | "Bash(git push --force-with-lease:*)", | |
| 151 | "Bash(git push --mirror:*)", | |
| 152 | ], | |
| 153 | "rewrite_default_branch" => &[ | |
| 154 | "Bash(git filter-branch:*)", | |
| 155 | "Bash(git filter-repo:*)", | |
| 156 | "Bash(git replace:*)", | |
| 157 | ], | |
| 158 | "outside_workspace" => &[ | |
| 159 | "Read(//proc/**)", | |
| 160 | "Read(//etc/claude-code/**)", | |
| 161 | "Read(//etc/g1t/**)", | |
| 162 | "Read(//work/.g1t/**)", | |
| 163 | "Read(//work/g1t-mcp.json)", | |
| 164 | "Read(//work/g1t-steer.json)", | |
| 165 | "Read(~/.claude/**)", | |
| 166 | ], | |
| 167 | "print_env" => &["Bash(env)", "Bash(printenv:*)", "Bash(export -p)"], | |
| 168 | "sudo" => &["Bash(sudo:*)", "Bash(su:*)", "Bash(doas:*)"], | |
| 169 | _ => &[], | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | /// Claude Code's settings for a guarded run: the hook before every tool | |
| 174 | /// call, the permission rules, and nothing sent anywhere but the model. | |
| 175 | pub fn harness_settings(policy: &Policy) -> Value { | |
| 176 | let mut deny: Vec<String> = Vec::new(); | |
| 177 | for (rule, on) in &policy.rules { | |
| 178 | if *on { | |
| 179 | deny.extend(rule_patterns(rule).iter().map(|pattern| (*pattern).to_owned())); | |
| 180 | } | |
| 181 | } | |
| 182 | for pattern in &policy.deny { | |
| 183 | if !deny.contains(pattern) { | |
| 184 | deny.push(pattern.clone()); | |
| 185 | } | |
| 186 | } | |
| 187 | let mut settings = json!({ | |
| 188 | "permissions": { "deny": deny }, | |
| 189 | "hooks": { | |
| 190 | "PreToolUse": [{ | |
| 191 | "matcher": "*", | |
| 192 | "hooks": [{ "type": "command", "command": HOOK_COMMAND, "timeout": 10 }], | |
| 193 | }], | |
| 194 | }, | |
| 195 | }); | |
| 196 | if policy.restrict_network { | |
| 197 | // Only the model and the allowed hosts are reachable: the harness | |
| 198 | // is told not to try anything else. | |
| 199 | settings["skipWebFetchPreflight"] = json!(true); | |
| 200 | settings["env"] = json!({ | |
| 201 | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", | |
| 202 | "DISABLE_TELEMETRY": "1", | |
| 203 | "DISABLE_ERROR_REPORTING": "1", | |
| 204 | "DISABLE_AUTOUPDATER": "1", | |
| 205 | }); | |
| 206 | } | |
| 207 | settings | |
| 208 | } | |
| 209 | ||
| 210 | /// What `install` managed to do. | |
| 211 | #[derive(Debug, Default)] | |
| 212 | pub struct Installed { | |
| 213 | /// Settings to pass with `--settings`, when they could not be managed. | |
| 214 | pub settings_file: Option<String>, | |
| 215 | /// Whether the sandbox gave up root. | |
| 216 | pub dropped_root: bool, | |
| 217 | } | |
| 218 | ||
| 219 | /// Puts the guardrails where the harness and the hook read them, then, if | |
| 220 | /// the `sudo` rule is on, gives up root for the rest of the sandbox. | |
| 221 | pub fn install(policy: &Policy, raw: &str) -> Installed { | |
| 222 | let _ = std::fs::create_dir_all("/work/.g1t"); | |
| 223 | let _ = std::fs::write(DENIED_LOG, ""); | |
| 224 | let settings = harness_settings(policy).to_string(); | |
| 225 | let managed = write_as_root(POLICY_FILES[0], raw) && write_as_root(MANAGED_SETTINGS, &settings); | |
| 226 | let mut installed = Installed::default(); | |
| 227 | if !managed { | |
| 228 | let _ = std::fs::write(POLICY_FILES[1], raw); | |
| 229 | if std::fs::write(FALLBACK_SETTINGS, &settings).is_ok() { | |
| 230 | installed.settings_file = Some(FALLBACK_SETTINGS.to_owned()); | |
| 231 | } | |
| 232 | } | |
| 233 | if policy.on("sudo") && managed { | |
| 234 | installed.dropped_root = sudo(&["rm", "-f", "/etc/sudoers.d/node"], None); | |
| 235 | } | |
| 236 | installed | |
| 237 | } | |
| 238 | ||
| 239 | /// A remote as `namespace/name`, lower case, whatever host or suffix it has. | |
| 240 | fn repo_of(remote: &str) -> String { | |
| 241 | let path = remote.split("://").nth(1).map_or(remote, |rest| rest.split_once('/').map_or("", |(_, path)| path)); | |
| 242 | path.trim_end_matches('/').trim_end_matches(".git").to_lowercase() | |
| 243 | } | |
| 244 | ||
| 245 | /// Whether the checkout the agent works in is the repository's own: a | |
| 246 | /// branch of the repository itself (the default branch, or one pushed to | |
| 247 | /// it), never a fork. A fork's files are anyone's, so the harness must not | |
| 248 | /// load their CLAUDE.md, settings, hooks, MCP servers or commands. Same | |
| 249 | /// rule as the instructions the runner service reads (repo-instructions.ts). | |
| 250 | /// Unsure is untrusted. | |
| 251 | pub fn checkout_trusted(remote: Option<&str>, upstream: Option<&str>, repo: Option<&str>) -> bool { | |
| 252 | let Some(remote) = remote.map(repo_of).filter(|remote| !remote.is_empty()) else { | |
| 253 | return false; | |
| 254 | }; | |
| 255 | let own = upstream | |
| 256 | .map(repo_of) | |
| 257 | .or_else(|| repo.map(|repo| repo.trim_matches('/').to_lowercase())) | |
| 258 | .filter(|own| !own.is_empty()); | |
| 259 | own.is_some_and(|own| own == remote) | |
| 260 | } | |
| 261 | ||
| 262 | /// The same, from the sandbox's environment. | |
| 263 | pub fn checkout_trusted_from_env() -> bool { | |
| 264 | let var = |name: &str| std::env::var(name).ok(); | |
| 265 | checkout_trusted( | |
| 266 | var("GIT_REMOTE").as_deref(), | |
| 267 | var("UPSTREAM_REMOTE").as_deref(), | |
| 268 | var("G1T_REPO").as_deref(), | |
| 269 | ) | |
| 270 | } | |
| 271 | ||
| 272 | /// Flags that keep Claude Code from loading anything from an untrusted | |
| 273 | /// checkout: its `.claude/settings.json` and `settings.local.json` (with | |
| 274 | /// their hooks and permissions), its `.mcp.json`, and its commands and | |
| 275 | /// skills. Managed settings (the guard hook), `--settings` and | |
| 276 | /// `--mcp-config` still apply. CLAUDE.md is turned off by | |
| 277 | /// `UNTRUSTED_ENV`. | |
| 278 | pub const UNTRUSTED_FLAGS: [&str; 4] = ["--setting-sources", "user", "--strict-mcp-config", "--disable-slash-commands"]; | |
| 279 | /// Turns off every CLAUDE.md, the checkout's included. | |
| 280 | pub const UNTRUSTED_ENV: (&str, &str) = ("CLAUDE_CODE_DISABLE_CLAUDE_MDS", "1"); | |
| 281 | ||
| 282 | /// Refusals the hook has written since the last call, as run steps. | |
| 283 | #[derive(Default)] | |
| 284 | pub struct Denials { | |
| 285 | seen: usize, | |
| 286 | } | |
| 287 | ||
| 288 | impl Denials { | |
| 289 | pub fn take(&mut self) -> Vec<String> { | |
| 290 | let Ok(text) = std::fs::read_to_string(DENIED_LOG) else { | |
| 291 | return Vec::new(); | |
| 292 | }; | |
| 293 | let lines: Vec<String> = text.lines().map(str::to_owned).collect(); | |
| 294 | let new = lines.iter().skip(self.seen).cloned().collect(); | |
| 295 | self.seen = lines.len(); | |
| 296 | new | |
| 297 | } | |
| 298 | } | |
| 299 | ||
| 300 | /// `MODE=guard`: Claude Code's `PreToolUse` hook. Reads the call on stdin; | |
| 301 | /// exits 2 with the reason on stderr to refuse it, which Claude Code shows | |
| 302 | /// the agent, and 0 to let it through. | |
| 303 | pub fn hook_main() -> i32 { | |
| 304 | let mut input = String::new(); | |
| 305 | let _ = std::io::stdin().read_to_string(&mut input); | |
| 306 | let Ok(call) = serde_json::from_str::<Value>(&input) else { | |
| 307 | return 0; | |
| 308 | }; | |
| 309 | let Some(policy) = POLICY_FILES | |
| 310 | .iter() | |
| 311 | .find_map(|path| std::fs::read_to_string(path).ok()) | |
| 312 | .and_then(|raw| serde_json::from_str::<Policy>(&raw).ok()) | |
| 313 | else { | |
| 314 | return 0; | |
| 315 | }; | |
| 316 | let tool = call["tool_name"].as_str().unwrap_or_default(); | |
| 317 | let home = std::env::var("HOME").unwrap_or_else(|_| "/home/node".to_owned()); | |
| 318 | let place = Place { | |
| 319 | workdir: call["cwd"].as_str().unwrap_or(crate::WORKDIR).to_owned(), | |
| 320 | home, | |
| 321 | default_branch: policy | |
| 322 | .default_branch | |
| 323 | .clone() | |
| 324 | .or_else(origin_default_branch) | |
| 325 | .unwrap_or_else(|| "main".to_owned()), | |
| 326 | }; | |
| 327 | let Some(reason) = decide(&policy, tool, &call["tool_input"], &place) else { | |
| 328 | return 0; | |
| 329 | }; | |
| 330 | let what = crate::progress::describe_tool(tool, &call["tool_input"]); | |
| 331 | if let Ok(mut log) = std::fs::OpenOptions::new().create(true).append(true).open(DENIED_LOG) { | |
| 332 | let _ = writeln!(log, "{}", crate::progress::one_line(&format!("Denied: {what} ({reason})"))); | |
| 333 | } | |
| 334 | eprintln!( | |
| 335 | "Blocked by g1t guardrails: {reason}. This project does not allow it. Do not try to get around it; if the task cannot be done without it, stop and say so in your summary." | |
| 336 | ); | |
| 337 | 2 | |
| 338 | } | |
| 339 | ||
| 340 | /// The default branch of the clone's origin, if git knows it. | |
| 341 | fn origin_default_branch() -> Option<String> { | |
| 342 | let output = Command::new("git") | |
| 343 | .current_dir(crate::WORKDIR) | |
| 344 | .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]) | |
| 345 | .output() | |
| 346 | .ok()?; | |
| 347 | let name = String::from_utf8_lossy(&output.stdout).trim().to_owned(); | |
| 348 | name.strip_prefix("origin/").map(str::to_owned) | |
| 349 | } | |
| 350 | ||
| 351 | /// Where a call is made from. | |
| 352 | pub struct Place { | |
| 353 | pub workdir: String, | |
| 354 | pub home: String, | |
| 355 | pub default_branch: String, | |
| 356 | } | |
| 357 | ||
| 358 | /// Why a tool call is refused, or None to let it through. | |
| 359 | pub fn decide(policy: &Policy, tool: &str, input: &Value, place: &Place) -> Option<String> { | |
| 360 | let field = |name: &str| input.get(name).and_then(Value::as_str).unwrap_or_default(); | |
| 361 | if tool == "Bash" { | |
| 362 | let command = field("command"); | |
| 363 | for segment in segments(command) { | |
| 364 | let words = words(&segment); | |
| 365 | if let Some(reason) = builtin_shell(policy, &words, &segment, place) { | |
| 366 | return Some(reason); | |
| 367 | } | |
| 368 | } | |
| 369 | } | |
| 370 | if let Some(path) = file_path(tool, input) { | |
| 371 | let resolved = resolve(&path, place); | |
| 372 | if policy.on("outside_workspace") && !inside_allowed(&resolved, place) { | |
| 373 | return Some(format!("{resolved} is outside the project")); | |
| 374 | } | |
| 375 | } | |
| 376 | custom(policy, tool, input, place) | |
| 377 | } | |
| 378 | ||
| 379 | /// The path a file tool works on. | |
| 380 | fn file_path(tool: &str, input: &Value) -> Option<String> { | |
| 381 | let field = |name: &str| input.get(name).and_then(Value::as_str).map(str::to_owned); | |
| 382 | match tool { | |
| 383 | "Read" | "Write" | "Edit" | "MultiEdit" => field("file_path"), | |
| 384 | "NotebookEdit" | "NotebookRead" => field("notebook_path"), | |
| 385 | "Glob" | "Grep" | "LS" => field("path"), | |
| 386 | _ => None, | |
| 387 | } | |
| 388 | } | |
| 389 | ||
| 390 | /// `path` made absolute against the working directory, without `.` or | |
| 391 | /// `..`. Always with `/`, as in the sandbox. | |
| 392 | fn resolve(path: &str, place: &Place) -> String { | |
| 393 | let expanded = match path.strip_prefix("~/") { | |
| 394 | Some(rest) => format!("{}/{rest}", place.home), | |
| 395 | None if path == "~" => place.home.clone(), | |
| 396 | None => path.to_owned(), | |
| 397 | }; | |
| 398 | let joined = if expanded.starts_with('/') { | |
| 399 | expanded | |
| 400 | } else { | |
| 401 | format!("{}/{expanded}", place.workdir) | |
| 402 | }; | |
| 403 | let mut parts: Vec<&str> = Vec::new(); | |
| 404 | for part in joined.split('/') { | |
| 405 | match part { | |
| 406 | "" | "." => {} | |
| 407 | ".." => { | |
| 408 | parts.pop(); | |
| 409 | } | |
| 410 | name => parts.push(name), | |
| 411 | } | |
| 412 | } | |
| 413 | format!("/{}", parts.join("/")) | |
| 414 | } | |
| 415 | ||
| 416 | /// Where file tools may go: the project, scratch space, and the caches | |
| 417 | /// where dependencies' sources are. | |
| 418 | fn inside_allowed(path: &str, place: &Place) -> bool { | |
| 419 | let roots = [ | |
| 420 | crate::WORKDIR.to_owned(), | |
| 421 | "/tmp".to_owned(), | |
| 422 | format!("{}/.cargo/registry", place.home), | |
| 423 | format!("{}/.cargo/git", place.home), | |
| 424 | format!("{}/go/pkg/mod", place.home), | |
| 425 | format!("{}/.rustup/toolchains", place.home), | |
| 426 | ]; | |
| 427 | roots | |
| 428 | .iter() | |
| 429 | .any(|root| path == root || path.strip_prefix(root.as_str()).is_some_and(|rest| rest.starts_with('/'))) | |
| 430 | } | |
| 431 | ||
| 432 | /// Paths of g1t's own a shell command may not touch. | |
| 433 | fn protected_paths(place: &Place) -> Vec<String> { | |
| 434 | vec![ | |
| 435 | "/etc/claude-code".to_owned(), | |
| 436 | "/etc/g1t".to_owned(), | |
| 437 | "/etc/cloudflare".to_owned(), | |
| 438 | "/work/.g1t".to_owned(), | |
| 439 | "/work/g1t-mcp.json".to_owned(), | |
| 440 | crate::steer::CONFIG.to_owned(), | |
| 441 | format!("{}/.claude", place.home), | |
| 442 | "~/.claude".to_owned(), | |
| 443 | "$HOME/.claude".to_owned(), | |
| 444 | ] | |
| 445 | } | |
| 446 | ||
| 447 | /// A shell command split into the commands it runs. | |
| 448 | pub fn segments(command: &str) -> Vec<String> { | |
| 449 | let mut out = Vec::new(); | |
| 450 | let mut current = String::new(); | |
| 451 | let mut quote: Option<char> = None; | |
| 452 | let chars: Vec<char> = command.chars().collect(); | |
| 453 | let mut i = 0; | |
| 454 | while i < chars.len() { | |
| 455 | let c = chars[i]; | |
| 456 | match quote { | |
| 457 | Some(q) => { | |
| 458 | if c == q { | |
| 459 | quote = None; | |
| 460 | } | |
| 461 | current.push(c); | |
| 462 | } | |
| 463 | None if c == '\'' || c == '"' => { | |
| 464 | quote = Some(c); | |
| 465 | current.push(c); | |
| 466 | } | |
| 467 | None if c == ';' || c == '\n' || c == '|' || c == '&' => { | |
| 468 | out.push(std::mem::take(&mut current)); | |
| 469 | // `&&` and `||` are one separator. | |
| 470 | if (c == '|' || c == '&') && chars.get(i + 1) == Some(&c) { | |
| 471 | i += 1; | |
| 472 | } | |
| 473 | } | |
| 474 | None if c == '(' || c == ')' || c == '`' || (c == '$' && chars.get(i + 1) == Some(&'(')) => { | |
| 475 | // A subshell or a command substitution runs a command of | |
| 476 | // its own. | |
| 477 | out.push(std::mem::take(&mut current)); | |
| 478 | } | |
| 479 | None => current.push(c), | |
| 480 | } | |
| 481 | i += 1; | |
| 482 | } | |
| 483 | out.push(current); | |
| 484 | out.into_iter() | |
| 485 | .map(|segment| segment.split_whitespace().collect::<Vec<_>>().join(" ")) | |
| 486 | .filter(|segment| !segment.is_empty()) | |
| 487 | .collect() | |
| 488 | } | |
| 489 | ||
| 490 | /// A command's words, with quotes removed, leading `VAR=value` | |
| 491 | /// assignments and wrappers such as `nohup` skipped. | |
| 492 | pub fn words(segment: &str) -> Vec<String> { | |
| 493 | let mut out = Vec::new(); | |
| 494 | let mut current = String::new(); | |
| 495 | let mut quote: Option<char> = None; | |
| 496 | let mut started = false; | |
| 497 | for c in segment.chars() { | |
| 498 | match quote { | |
| 499 | Some(q) if c == q => quote = None, | |
| 500 | Some(_) => current.push(c), | |
| 501 | None if c == '\'' || c == '"' => { | |
| 502 | quote = Some(c); | |
| 503 | started = true; | |
| 504 | } | |
| 505 | None if c.is_whitespace() => { | |
| 506 | if started || !current.is_empty() { | |
| 507 | out.push(std::mem::take(&mut current)); | |
| 508 | started = false; | |
| 509 | } | |
| 510 | } | |
| 511 | None if c == '{' || c == '}' => {} | |
| 512 | None => current.push(c), | |
| 513 | } | |
| 514 | } | |
| 515 | if started || !current.is_empty() { | |
| 516 | out.push(current); | |
| 517 | } | |
| 518 | let skip = out | |
| 519 | .iter() | |
| 520 | .take_while(|word| { | |
| 521 | is_assignment(word) | |
| 522 | || matches!(word.as_str(), "nohup" | "time" | "exec" | "command" | "builtin" | "then" | "do" | "else" | "!") | |
| 523 | }) | |
| 524 | .count(); | |
| 525 | out.split_off(skip) | |
| 526 | } | |
| 527 | ||
| 528 | fn is_assignment(word: &str) -> bool { | |
| 529 | word.split_once('=').is_some_and(|(name, _)| { | |
| 530 | !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') | |
| 531 | }) | |
| 532 | } | |
| 533 | ||
| 534 | /// The name of a program, without its directory. | |
| 535 | fn program(word: &str) -> &str { | |
| 536 | word.rsplit('/').next().unwrap_or(word) | |
| 537 | } | |
| 538 | ||
| 539 | /// git's subcommand and its arguments, past options such as `-C dir`. | |
| 540 | fn git_command(words: &[String]) -> Option<(&str, &[String])> { | |
| 541 | if words.first().map(|word| program(word)) != Some("git") { | |
| 542 | return None; | |
| 543 | } | |
| 544 | let mut i = 1; | |
| 545 | while i < words.len() { | |
| 546 | let word = words[i].as_str(); | |
| 547 | if word == "-C" || word == "-c" || word == "--git-dir" || word == "--work-tree" { | |
| 548 | i += 2; | |
| 549 | } else if word.starts_with('-') { | |
| 550 | i += 1; | |
| 551 | } else { | |
| 552 | return Some((word, &words[i + 1..])); | |
| 553 | } | |
| 554 | } | |
| 555 | None | |
| 556 | } | |
| 557 | ||
| 558 | /// Whether a variable's name looks like it holds a secret. | |
| 559 | fn secret_name(name: &str) -> bool { | |
| 560 | let upper = name.to_uppercase(); | |
| 561 | ["TOKEN", "KEY", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "AUTH"] | |
| 562 | .iter() | |
| 563 | .any(|part| upper.contains(part)) | |
| 564 | } | |
| 565 | ||
| 566 | /// Variables a command reads, by name: `$NAME` and `${NAME}`. | |
| 567 | fn variables(segment: &str) -> Vec<String> { | |
| 568 | let mut out = Vec::new(); | |
| 569 | let chars: Vec<char> = segment.chars().collect(); | |
| 570 | let mut i = 0; | |
| 571 | while i < chars.len() { | |
| 572 | if chars[i] == '$' { | |
| 573 | let mut j = i + 1; | |
| 574 | if chars.get(j) == Some(&'{') { | |
| 575 | j += 1; | |
| 576 | } | |
| 577 | let start = j; | |
| 578 | while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') { | |
| 579 | j += 1; | |
| 580 | } | |
| 581 | if j > start { | |
| 582 | out.push(chars[start..j].iter().collect()); | |
| 583 | } | |
| 584 | i = j; | |
| 585 | } else { | |
| 586 | i += 1; | |
| 587 | } | |
| 588 | } | |
| 589 | out | |
| 590 | } | |
| 591 | ||
| 592 | /// The built-in rules, for one command of a shell line. | |
| 593 | fn builtin_shell(policy: &Policy, words: &[String], segment: &str, place: &Place) -> Option<String> { | |
| 594 | let first = words.first().map(|word| program(word)).unwrap_or_default(); | |
| 595 | let args = words.get(1..).unwrap_or_default(); | |
| 596 | ||
| 597 | if policy.on("sudo") && matches!(first, "sudo" | "su" | "doas" | "pkexec") { | |
| 598 | return Some("no sudo".to_owned()); | |
| 599 | } | |
| 600 | ||
| 601 | if let Some((sub, rest)) = git_command(words) { | |
| 602 | if sub == "push" { | |
| 603 | let options: Vec<&str> = rest.iter().map(String::as_str).filter(|a| a.starts_with('-')).collect(); | |
| 604 | let positional: Vec<&str> = rest.iter().map(String::as_str).filter(|a| !a.starts_with('-')).collect(); | |
| 605 | let refspecs = positional.get(1..).unwrap_or_default(); | |
| 606 | if policy.on("force_push") { | |
| 607 | let forced = options.iter().any(|option| { | |
| 608 | matches!(*option, "--force" | "--mirror" | "--delete" | "--prune" | "--force-if-includes") | |
| 609 | || option.starts_with("--force-with-lease") | |
| 610 | || (!option.starts_with("--") && (option.contains('f') || option.contains('d'))) | |
| 611 | }); | |
| 612 | if forced || refspecs.iter().any(|spec| spec.starts_with('+') || spec.starts_with(':')) { | |
| 613 | return Some("no force-pushing".to_owned()); | |
| 614 | } | |
| 615 | } | |
| 616 | if policy.on("rewrite_default_branch") { | |
| 617 | let branch = place.default_branch.as_str(); | |
| 618 | let targets_default = refspecs.iter().any(|spec| { | |
| 619 | let target = spec.rsplit(':').next().unwrap_or(spec).trim_start_matches('+'); | |
| 620 | target == branch || target == format!("refs/heads/{branch}") | |
| 621 | }); | |
| 622 | if targets_default || options.contains(&"--all") { | |
| 623 | return Some(format!("no pushing to {branch}, the default branch")); | |
| 624 | } | |
| 625 | } | |
| 626 | } | |
| 627 | if policy.on("rewrite_default_branch") { | |
| 628 | let branch = place.default_branch.as_str(); | |
| 629 | let names_default = rest | |
| 630 | .iter() | |
| 631 | .any(|arg| arg == branch || *arg == format!("refs/heads/{branch}")); | |
| 632 | let moves = rest | |
| 633 | .iter() | |
| 634 | .any(|arg| matches!(arg.as_str(), "-f" | "--force" | "-D" | "-d" | "--delete" | "-m" | "-M" | "--move")); | |
| 635 | match sub { | |
| 636 | "filter-branch" | "filter-repo" | "replace" => { | |
| 637 | return Some("no rewriting history".to_owned()); | |
| 638 | } | |
| 639 | "branch" if names_default && moves => { | |
| 640 | return Some(format!("no moving or deleting {branch}, the default branch")); | |
| 641 | } | |
| 642 | "update-ref" if names_default => { | |
| 643 | return Some(format!("no moving {branch}, the default branch")); | |
| 644 | } | |
| 645 | _ => {} | |
| 646 | } | |
| 647 | } | |
| 648 | } | |
| 649 | ||
| 650 | if policy.on("print_env") { | |
| 651 | let prints = match first { | |
| 652 | "printenv" => true, | |
| 653 | // `env` alone, or with only options and assignments: no command. | |
| 654 | "env" => args.iter().all(|arg| arg.starts_with('-') || is_assignment(arg)), | |
| 655 | "export" | "declare" | "typeset" => args.is_empty() || args.iter().any(|arg| arg == "-p" || arg == "-x"), | |
| 656 | "set" => args.is_empty(), | |
| 657 | "compgen" => args.iter().any(|arg| arg == "-e" || arg == "-v"), | |
| 658 | _ => false, | |
| 659 | }; | |
| 660 | let environ = segment.contains("/proc/") && segment.contains("environ"); | |
| 661 | let secret = variables(segment).iter().any(|name| secret_name(name)); | |
| 662 | if prints || environ || secret { | |
| 663 | return Some("no printing the environment".to_owned()); | |
| 664 | } | |
| 665 | } | |
| 666 | ||
| 667 | if policy.on("outside_workspace") { | |
| 668 | let touched = protected_paths(place) | |
| 669 | .into_iter() | |
| 670 | .find(|path| segment.contains(path.as_str())); | |
| 671 | if let Some(path) = touched { | |
| 672 | return Some(format!("{path} is g1t's, not the project's")); | |
| 673 | } | |
| 674 | if segment.contains("/proc/") && segment.contains("environ") { | |
| 675 | return Some("no reading other processes' environments".to_owned()); | |
| 676 | } | |
| 677 | } | |
| 678 | None | |
| 679 | } | |
| 680 | ||
| 681 | /// The tools a permission rule's tool name covers, as Claude Code reads | |
| 682 | /// them: an `Edit` rule covers every tool that writes a file. | |
| 683 | fn rule_covers(rule_tool: &str, tool: &str) -> bool { | |
| 684 | match rule_tool { | |
| 685 | "Edit" | "Write" => matches!(tool, "Edit" | "Write" | "MultiEdit" | "NotebookEdit"), | |
| 686 | "Read" => matches!(tool, "Read" | "Glob" | "Grep" | "NotebookRead" | "LS"), | |
| 687 | other => other == tool, | |
| 688 | } | |
| 689 | } | |
| 690 | ||
| 691 | /// The workspace's own deny patterns. | |
| 692 | fn custom(policy: &Policy, tool: &str, input: &Value, place: &Place) -> Option<String> { | |
| 693 | for pattern in &policy.deny { | |
| 694 | let (rule_tool, spec) = match pattern.split_once('(') { | |
| 695 | Some((name, rest)) => (name, rest.strip_suffix(')')), | |
| 696 | None => (pattern.as_str(), None), | |
| 697 | }; | |
| 698 | if !rule_covers(rule_tool, tool) { | |
| 699 | continue; | |
| 700 | } | |
| 701 | let matched = match spec { | |
| 702 | None => true, | |
| 703 | Some(spec) if tool == "Bash" => { | |
| 704 | let command = input.get("command").and_then(Value::as_str).unwrap_or_default(); | |
| 705 | segments(command).iter().any(|segment| shell_matches(spec, segment)) | |
| 706 | } | |
| 707 | Some(spec) if rule_tool == "WebFetch" => { | |
| 708 | let url = input.get("url").and_then(Value::as_str).unwrap_or_default(); | |
| 709 | let host = url.split("://").nth(1).unwrap_or(url).split(['/', ':']).next().unwrap_or_default(); | |
| 710 | let domain = spec.strip_prefix("domain:").unwrap_or(spec); | |
| 711 | host == domain || host.ends_with(&format!(".{domain}")) | |
| 712 | } | |
| 713 | Some(spec) => match file_path(tool, input) { | |
| 714 | Some(path) => { | |
| 715 | let path = resolve(&path, place); | |
| 716 | glob(&expand_rule_path(spec, place), &path) | |
| 717 | } | |
| 718 | None => false, | |
| 719 | }, | |
| 720 | }; | |
| 721 | if matched { | |
| 722 | return Some(format!("{pattern} is on this workspace's deny list")); | |
| 723 | } | |
| 724 | } | |
| 725 | None | |
| 726 | } | |
| 727 | ||
| 728 | /// A rule's path made absolute, as Claude Code reads it: `//` is the root | |
| 729 | /// and `~/` the home directory; anything else is the project's. | |
| 730 | fn expand_rule_path(spec: &str, place: &Place) -> String { | |
| 731 | if let Some(rest) = spec.strip_prefix("//") { | |
| 732 | format!("/{rest}") | |
| 733 | } else if let Some(rest) = spec.strip_prefix("~/") { | |
| 734 | format!("{}/{rest}", place.home) | |
| 735 | } else if let Some(rest) = spec.strip_prefix('/') { | |
| 736 | format!("{}/{rest}", crate::WORKDIR) | |
| 737 | } else { | |
| 738 | format!("{}/{spec}", crate::WORKDIR) | |
| 739 | } | |
| 740 | } | |
| 741 | ||
| 742 | /// Whether one command matches a `Bash(...)` rule: `prefix:*` for a | |
| 743 | /// command that starts with those words, `*` anywhere as a wildcard, or | |
| 744 | /// the exact command. | |
| 745 | pub fn shell_matches(spec: &str, segment: &str) -> bool { | |
| 746 | let spec = spec.split_whitespace().collect::<Vec<_>>().join(" "); | |
| 747 | let segment = words(segment).join(" "); | |
| 748 | if let Some(prefix) = spec.strip_suffix(":*") { | |
| 749 | return segment == prefix | |
| 750 | || segment | |
| 751 | .strip_prefix(prefix) | |
| 752 | .is_some_and(|rest| rest.starts_with(' ')); | |
| 753 | } | |
| 754 | if spec.contains('*') { | |
| 755 | return wildcard(&spec, &segment, false); | |
| 756 | } | |
| 757 | segment == spec | |
| 758 | } | |
| 759 | ||
| 760 | /// A path glob: `**` crosses directories, `*` and `?` do not. | |
| 761 | pub fn glob(pattern: &str, path: &str) -> bool { | |
| 762 | wildcard(pattern, path, true) | |
| 763 | } | |
| 764 | ||
| 765 | fn wildcard(pattern: &str, text: &str, paths: bool) -> bool { | |
| 766 | let p: Vec<char> = pattern.chars().collect(); | |
| 767 | let t: Vec<char> = text.chars().collect(); | |
| 768 | fn go(p: &[char], t: &[char], paths: bool) -> bool { | |
| 769 | match p.first() { | |
| 770 | None => t.is_empty(), | |
| 771 | Some('*') if paths && p.get(1) == Some(&'*') => { | |
| 772 | let rest = if p.get(2) == Some(&'/') { &p[3..] } else { &p[2..] }; | |
| 773 | (0..=t.len()).any(|i| go(rest, &t[i..], paths)) | |
| 774 | || (p.get(2) == Some(&'/') && go(&p[2..], t, paths)) | |
| 775 | } | |
| 776 | Some('*') => (0..=t.len()) | |
| 777 | .take_while(|i| !paths || *i == 0 || t[i - 1] != '/') | |
| 778 | .any(|i| go(&p[1..], &t[i..], paths)), | |
| 779 | Some('?') => !t.is_empty() && (!paths || t[0] != '/') && go(&p[1..], &t[1..], paths), | |
| 780 | Some(c) => t.first() == Some(c) && go(&p[1..], &t[1..], paths), | |
| 781 | } | |
| 782 | } | |
| 783 | go(&p, &t, paths) | |
| 784 | } | |
| 785 | ||
| 786 | #[cfg(test)] | |
| 787 | mod tests { | |
| 788 | use super::*; | |
| 789 | ||
| 790 | fn all_on() -> Policy { | |
| 791 | Policy { | |
| 792 | rules: ["force_push", "rewrite_default_branch", "outside_workspace", "print_env", "sudo"] | |
| 793 | .into_iter() | |
| 794 | .map(|rule| (rule.to_owned(), true)) | |
| 795 | .collect(), | |
| 796 | ..Policy::default() | |
| 797 | } | |
| 798 | } | |
| 799 | ||
| 800 | fn place() -> Place { | |
| 801 | Place { | |
| 802 | workdir: "/work/repo".to_owned(), | |
| 803 | home: "/home/node".to_owned(), | |
| 804 | default_branch: "main".to_owned(), | |
| 805 | } | |
| 806 | } | |
| 807 | ||
| 808 | fn bash(policy: &Policy, command: &str) -> Option<String> { | |
| 809 | decide(policy, "Bash", &json!({ "command": command }), &place()) | |
| 810 | } | |
| 811 | ||
| 812 | #[test] | |
| 813 | fn force_pushes_are_refused_however_written() { | |
| 814 | let policy = all_on(); | |
| 815 | for command in [ | |
| 816 | "git push --force", | |
| 817 | "git push origin feature -f", | |
| 818 | "git push --force-with-lease=main origin feature", | |
| 819 | "git -C /work/repo push origin +feature", | |
| 820 | "cd x && git push origin :old", | |
| 821 | "git push -fu origin feature", | |
| 822 | "GIT_TRACE=1 git push --mirror", | |
| 823 | ] { | |
| 824 | assert_eq!(bash(&policy, command).as_deref(), Some("no force-pushing"), "{command}"); | |
| 825 | } | |
| 826 | assert_eq!(bash(&policy, "git push origin feature"), None); | |
| 827 | assert_eq!(bash(&policy, "git push -u origin feature"), None); | |
| 828 | } | |
| 829 | ||
| 830 | #[test] | |
| 831 | fn the_default_branch_is_not_rewritten() { | |
| 832 | let policy = all_on(); | |
| 833 | assert!(bash(&policy, "git push origin HEAD:main").is_some()); | |
| 834 | assert!(bash(&policy, "git push origin main").is_some()); | |
| 835 | assert!(bash(&policy, "git push origin feature:refs/heads/main").is_some()); | |
| 836 | assert!(bash(&policy, "git branch -f main HEAD~3").is_some()); | |
| 837 | assert!(bash(&policy, "git update-ref refs/heads/main abc123").is_some()); | |
| 838 | assert!(bash(&policy, "git filter-branch --tree-filter 'rm x' HEAD").is_some()); | |
| 839 | assert_eq!(bash(&policy, "git branch feature"), None); | |
| 840 | assert_eq!(bash(&policy, "git rebase main"), None); | |
| 841 | assert_eq!(bash(&policy, "git checkout main"), None); | |
| 842 | } | |
| 843 | ||
| 844 | #[test] | |
| 845 | fn printing_the_environment_is_refused() { | |
| 846 | let policy = all_on(); | |
| 847 | for command in [ | |
| 848 | "env", | |
| 849 | "env | grep KEY", | |
| 850 | "printenv ANTHROPIC_API_KEY", | |
| 851 | "export -p", | |
| 852 | "set", | |
| 853 | "cat /proc/self/environ", | |
| 854 | "echo $ANTHROPIC_API_KEY", | |
| 855 | "curl -H \"Authorization: ${GITHUB_TOKEN}\" x", | |
| 856 | ] { | |
| 857 | assert_eq!(bash(&policy, command).as_deref(), Some("no printing the environment"), "{command}"); | |
| 858 | } | |
| 859 | assert_eq!(bash(&policy, "env NODE_ENV=test npm test"), None); | |
| 860 | assert_eq!(bash(&policy, "export PATH=$PATH:/x"), None); | |
| 861 | assert_eq!(bash(&policy, "echo $HOME"), None); | |
| 862 | assert_eq!(bash(&policy, "set -e"), None); | |
| 863 | } | |
| 864 | ||
| 865 | #[test] | |
| 866 | fn sudo_is_refused() { | |
| 867 | let policy = all_on(); | |
| 868 | assert_eq!(bash(&policy, "sudo apt-get install jq").as_deref(), Some("no sudo")); | |
| 869 | assert_eq!(bash(&policy, "npm ci && sudo rm -rf /").as_deref(), Some("no sudo")); | |
| 870 | assert_eq!(bash(&policy, "echo $(sudo cat /etc/shadow)").as_deref(), Some("no sudo")); | |
| 871 | assert_eq!(bash(&policy, "npm test"), None); | |
| 872 | // Quoted, it is text. | |
| 873 | assert_eq!(bash(&policy, "echo 'do not use sudo'"), None); | |
| 874 | } | |
| 875 | ||
| 876 | #[test] | |
| 877 | fn files_outside_the_project_are_refused() { | |
| 878 | let policy = all_on(); | |
| 879 | let read = |path: &str| decide(&policy, "Read", &json!({ "file_path": path }), &place()); | |
| 880 | assert_eq!(read("/work/repo/src/lib.rs"), None); | |
| 881 | assert_eq!(read("src/lib.rs"), None); | |
| 882 | assert_eq!(read("/tmp/out.txt"), None); | |
| 883 | assert_eq!(read("/home/node/.cargo/registry/src/serde/lib.rs"), None); | |
| 884 | assert!(read("/etc/passwd").is_some()); | |
| 885 | assert!(read("../../etc/passwd").is_some()); | |
| 886 | assert!(read("/work/repo/../g1t-mcp.json").is_some()); | |
| 887 | assert!(read("~/.claude/settings.json").is_some()); | |
| 888 | assert!(decide(&policy, "Grep", &json!({ "pattern": "x", "path": "/etc" }), &place()).is_some()); | |
| 889 | assert!(bash(&policy, "cat /work/g1t-mcp.json").is_some()); | |
| 890 | assert!(bash(&policy, "cat ~/.claude/settings.json").is_some()); | |
| 891 | assert!(bash(&policy, "rm /work/.g1t/denied.log").is_some()); | |
| 892 | } | |
| 893 | ||
| 894 | #[test] | |
| 895 | fn rules_that_are_off_let_things_through() { | |
| 896 | let policy = Policy::default(); | |
| 897 | assert_eq!(bash(&policy, "git push --force"), None); | |
| 898 | assert_eq!(bash(&policy, "sudo true"), None); | |
| 899 | assert_eq!(bash(&policy, "env"), None); | |
| 900 | assert_eq!(decide(&policy, "Read", &json!({ "file_path": "/etc/passwd" }), &place()), None); | |
| 901 | } | |
| 902 | ||
| 903 | #[test] | |
| 904 | fn custom_patterns_match_as_permission_rules_do() { | |
| 905 | let policy = Policy { | |
| 906 | deny: vec![ | |
| 907 | "Bash(terraform apply:*)".into(), | |
| 908 | "Bash(rm -rf *)".into(), | |
| 909 | "Edit(//etc/**)".into(), | |
| 910 | "Read(secrets/**)".into(), | |
| 911 | "WebFetch(domain:example.com)".into(), | |
| 912 | "WebSearch".into(), | |
| 913 | ], | |
| 914 | ..Policy::default() | |
| 915 | }; | |
| 916 | assert!(bash(&policy, "terraform apply -auto-approve").is_some()); | |
| 917 | assert!(bash(&policy, "cd infra && terraform apply").is_some()); | |
| 918 | assert_eq!(bash(&policy, "terraform applyx"), None); | |
| 919 | assert_eq!(bash(&policy, "terraform plan"), None); | |
| 920 | assert!(bash(&policy, "rm -rf build").is_some()); | |
| 921 | let write = |path: &str| decide(&policy, "Write", &json!({ "file_path": path }), &place()); | |
| 922 | assert!(write("/etc/hosts").is_some()); | |
| 923 | assert_eq!(write("/work/repo/etc/hosts"), None); | |
| 924 | let read = |path: &str| decide(&policy, "Read", &json!({ "file_path": path }), &place()); | |
| 925 | assert!(read("secrets/prod/key.pem").is_some()); | |
| 926 | assert_eq!(read("src/secrets.rs"), None); | |
| 927 | assert!(decide(&policy, "WebFetch", &json!({ "url": "https://docs.example.com/x" }), &place()).is_some()); | |
| 928 | assert_eq!(decide(&policy, "WebFetch", &json!({ "url": "https://example.org" }), &place()), None); | |
| 929 | assert!(decide(&policy, "WebSearch", &json!({ "query": "x" }), &place()).is_some()); | |
| 930 | } | |
| 931 | ||
| 932 | #[test] | |
| 933 | fn commands_split_where_the_shell_does() { | |
| 934 | assert_eq!(segments("a && b || c; d | e & f"), vec!["a", "b", "c", "d", "e", "f"]); | |
| 935 | assert_eq!(segments("echo 'a; b' && c"), vec!["echo 'a; b'", "c"]); | |
| 936 | assert_eq!(segments("x $(sudo y) `z`"), vec!["x", "sudo y", "z"]); | |
| 937 | assert_eq!(words("FOO=1 nohup \"git\" push"), vec!["git", "push"]); | |
| 938 | } | |
| 939 | ||
| 940 | #[test] | |
| 941 | fn globs_respect_directories() { | |
| 942 | assert!(glob("/etc/**", "/etc/a/b")); | |
| 943 | assert!(glob("/work/repo/**/*.pem", "/work/repo/a/b/key.pem")); | |
| 944 | assert!(glob("/work/repo/**/*.pem", "/work/repo/key.pem")); | |
| 945 | assert!(!glob("/work/repo/*.pem", "/work/repo/a/key.pem")); | |
| 946 | assert!(glob("/work/repo/?.rs", "/work/repo/a.rs")); | |
| 947 | } | |
| 948 | ||
| 949 | #[test] | |
| 950 | fn harness_settings_carry_the_hook_and_the_rules() { | |
| 951 | let mut policy = all_on(); | |
| 952 | policy.deny = vec!["Bash(kubectl:*)".into()]; | |
| 953 | policy.restrict_network = true; | |
| 954 | let settings = harness_settings(&policy); | |
| 955 | let deny: Vec<&str> = settings["permissions"]["deny"] | |
| 956 | .as_array() | |
| 957 | .unwrap() | |
| 958 | .iter() | |
| 959 | .filter_map(Value::as_str) | |
| 960 | .collect(); | |
| 961 | assert!(deny.contains(&"Bash(git push --force:*)")); | |
| 962 | assert!(deny.contains(&"Bash(sudo:*)")); | |
| 963 | assert!(deny.contains(&"Bash(kubectl:*)")); | |
| 964 | assert_eq!( | |
| 965 | settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"], | |
| 966 | "MODE=guard /usr/local/bin/g1t-runner" | |
| 967 | ); | |
| 968 | assert_eq!(settings["env"]["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"], "1"); | |
| 969 | ||
| 970 | let open = harness_settings(&Policy::default()); | |
| 971 | assert_eq!(open["permissions"]["deny"].as_array().unwrap().len(), 0); | |
| 972 | assert!(open.get("env").is_none()); | |
| 973 | // The hook runs whatever the rules, for the workspace's own patterns. | |
| 974 | assert!(open["hooks"]["PreToolUse"].is_array()); | |
| 975 | } | |
| 976 | ||
| 977 | #[test] | |
| 978 | fn the_policy_reads_as_the_runner_sends_it() { | |
| 979 | let policy: Policy = serde_json::from_str( | |
| 980 | r#"{"rules":{"sudo":true,"print_env":false},"deny":["Bash(x:*)"],"budgetUsd":5,"minutes":90,"restrictNetwork":true,"defaultBranch":null}"#, | |
| 981 | ) | |
| 982 | .unwrap(); | |
| 983 | assert!(policy.on("sudo")); | |
| 984 | assert!(!policy.on("print_env")); | |
| 985 | assert_eq!(policy.budget_usd, Some(5.0)); | |
| 986 | assert_eq!(policy.minutes, Some(90)); | |
| 987 | assert!(policy.restrict_network); | |
| 988 | } | |
| 989 | ||
| 990 | #[test] | |
| 991 | fn only_the_repositorys_own_checkout_is_trusted() { | |
| 992 | let repo = Some("acme/site"); | |
| 993 | // The default branch or a branch of the repository itself. | |
| 994 | assert!(checkout_trusted(Some("https://g1t.sh/acme/site.git"), None, repo)); | |
| 995 | assert!(checkout_trusted( | |
| 996 | Some("https://g1t.sh/Acme/Site"), | |
| 997 | Some("https://g1t.sh/acme/site.git"), | |
| 998 | None | |
| 999 | )); | |
| 1000 | // A fork's head, whoever's fork it is, g1t-agent's included. | |
| 1001 | assert!(!checkout_trusted(Some("https://g1t.sh/g1t-agent/site-12.git"), None, repo)); | |
| 1002 | assert!(!checkout_trusted( | |
| 1003 | Some("https://g1t.sh/someone/site.git"), | |
| 1004 | Some("https://g1t.sh/acme/site.git"), | |
| 1005 | repo | |
| 1006 | )); | |
| 1007 | // Not knowing is not trusting. | |
| 1008 | assert!(!checkout_trusted(Some("https://g1t.sh/acme/site.git"), None, None)); | |
| 1009 | assert!(!checkout_trusted(None, None, repo)); | |
| 1010 | // A look-alike name is another repository. | |
| 1011 | assert!(!checkout_trusted(Some("https://g1t.sh/acme/site-evil.git"), None, repo)); | |
| 1012 | } | |
| 1013 | ||
| 1014 | #[test] | |
| 1015 | fn untrusted_checkouts_load_nothing_of_their_own() { | |
| 1016 | let flags = UNTRUSTED_FLAGS.join(" "); | |
| 1017 | assert!(flags.contains("--setting-sources user")); | |
| 1018 | assert!(!flags.contains("project") && !flags.contains("local")); | |
| 1019 | assert!(flags.contains("--strict-mcp-config")); | |
| 1020 | assert!(flags.contains("--disable-slash-commands")); | |
| 1021 | assert_eq!(UNTRUSTED_ENV, ("CLAUDE_CODE_DISABLE_CLAUDE_MDS", "1")); | |
| 1022 | } | |
| 1023 | ||
| 1024 | #[test] | |
| 1025 | fn halts_are_told_apart_from_failures() { | |
| 1026 | assert!(is_halt(&anyhow::Error::new(Halted::Budget))); | |
| 1027 | assert!(!is_halt(&anyhow::anyhow!("the agent reported an error"))); | |
| 1028 | assert_eq!(Halted::Time.reason(), "time"); | |
| 1029 | } | |
| 1030 | } |