g1t/crates/runner/src/guard.rs

1,070 lines42,301 bytesCodeBlame

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 API1//! 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
25use std::collections::BTreeMap;
26use std::fmt;
27use std::io::{Read, Write};
28use std::path::Path;
29use std::process::{Command, Stdio};
30
31use serde::Deserialize;
32use serde_json::{Value, json};
33
34/// Where the guardrails are kept for the hook: root-owned where it can be.
35const POLICY_FILES: [&str; 2] = ["/etc/g1t/guard.json", "/work/.g1t/guard.json"];
36const MANAGED_SETTINGS: &str = "/etc/claude-code/managed-settings.json";
37/// Settings passed with `--settings` when the managed file cannot be written.
38pub const FALLBACK_SETTINGS: &str = "/work/.g1t/settings.json";
39/// Refusals, one per line, for the harness to report.
40pub const DENIED_LOG: &str = "/work/.g1t/denied.log";
41/// The certificate the sandbox's HTTPS is re-signed with, when it is guarded.
42const EGRESS_CA: &str = "/etc/cloudflare/certs/cloudflare-containers-ca.crt";
43const 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)]
48pub 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
59impl 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)]
71pub enum Halted {
72 Budget,
73 Time,
74}
75
76impl Halted {
77 pub fn reason(self) -> &'static str {
78 match self {
79 Halted::Budget => "budget",
80 Halted::Time => "time",
81 }
82 }
83}
84
85impl 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
94impl std::error::Error for Halted {}
95
96/// Whether a run failed because it reached a cap, rather than failing.
97pub 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.
102fn 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.
120fn 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.
131pub 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.
145fn 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.
175pub 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)]
212pub 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.
221pub 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.
240fn 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.
251pub 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.
263pub 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`.
278pub const UNTRUSTED_FLAGS: [&str; 4] = ["--setting-sources", "user", "--strict-mcp-config", "--disable-slash-commands"];
279/// Turns off every CLAUDE.md, the checkout's included.
280pub 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)]
284pub struct Denials {
285 seen: usize,
286}
287
288impl 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.
303pub 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.
341fn 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.
352pub 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.
359pub 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");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look363 // Always on, whatever the project's rules: no sandbox mines.
364 if let Some(miner) = crate::abuse::miner_in(command) {
365 return Some(format!("no cryptocurrency mining ({miner})"));
366 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API367 for segment in segments(command) {
368 let words = words(&segment);
369 if let Some(reason) = builtin_shell(policy, &words, &segment, place) {
370 return Some(reason);
371 }
372 }
373 }
374 if let Some(path) = file_path(tool, input) {
375 let resolved = resolve(&path, place);
376 if policy.on("outside_workspace") && !inside_allowed(&resolved, place) {
377 return Some(format!("{resolved} is outside the project"));
378 }
379 }
380 custom(policy, tool, input, place)
381}
382
383/// The path a file tool works on.
384fn file_path(tool: &str, input: &Value) -> Option<String> {
385 let field = |name: &str| input.get(name).and_then(Value::as_str).map(str::to_owned);
386 match tool {
387 "Read" | "Write" | "Edit" | "MultiEdit" => field("file_path"),
388 "NotebookEdit" | "NotebookRead" => field("notebook_path"),
389 "Glob" | "Grep" | "LS" => field("path"),
390 _ => None,
391 }
392}
393
394/// `path` made absolute against the working directory, without `.` or
395/// `..`. Always with `/`, as in the sandbox.
396fn resolve(path: &str, place: &Place) -> String {
397 let expanded = match path.strip_prefix("~/") {
398 Some(rest) => format!("{}/{rest}", place.home),
399 None if path == "~" => place.home.clone(),
400 None => path.to_owned(),
401 };
402 let joined = if expanded.starts_with('/') {
403 expanded
404 } else {
405 format!("{}/{expanded}", place.workdir)
406 };
407 let mut parts: Vec<&str> = Vec::new();
408 for part in joined.split('/') {
409 match part {
410 "" | "." => {}
411 ".." => {
412 parts.pop();
413 }
414 name => parts.push(name),
415 }
416 }
417 format!("/{}", parts.join("/"))
418}
419
Reviews, plans and replies are written where the guardrail allows: g1t's answer files are inside it420/// The files the runner asks the agent to write its answer to, outside
421/// the project so they are never committed: a review, a plan, a reply.
422const ANSWER_FILES: [&str; 3] = [crate::review::REVIEW_FILE, crate::plan::PLAN_FILE, crate::reply::ANSWER_FILE];
423
424/// Where file tools may go: the project, scratch space, the caches where
425/// dependencies' sources are, and the answer files.
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API426fn inside_allowed(path: &str, place: &Place) -> bool {
Reviews, plans and replies are written where the guardrail allows: g1t's answer files are inside it427 if ANSWER_FILES.contains(&path) {
428 return true;
429 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API430 let roots = [
431 crate::WORKDIR.to_owned(),
432 "/tmp".to_owned(),
433 format!("{}/.cargo/registry", place.home),
434 format!("{}/.cargo/git", place.home),
435 format!("{}/go/pkg/mod", place.home),
436 format!("{}/.rustup/toolchains", place.home),
437 ];
438 roots
439 .iter()
440 .any(|root| path == root || path.strip_prefix(root.as_str()).is_some_and(|rest| rest.starts_with('/')))
441}
442
443/// Paths of g1t's own a shell command may not touch.
444fn protected_paths(place: &Place) -> Vec<String> {
445 vec![
446 "/etc/claude-code".to_owned(),
447 "/etc/g1t".to_owned(),
448 "/etc/cloudflare".to_owned(),
449 "/work/.g1t".to_owned(),
450 "/work/g1t-mcp.json".to_owned(),
451 crate::steer::CONFIG.to_owned(),
452 format!("{}/.claude", place.home),
453 "~/.claude".to_owned(),
454 "$HOME/.claude".to_owned(),
455 ]
456}
457
458/// A shell command split into the commands it runs.
459pub fn segments(command: &str) -> Vec<String> {
460 let mut out = Vec::new();
461 let mut current = String::new();
462 let mut quote: Option<char> = None;
463 let chars: Vec<char> = command.chars().collect();
464 let mut i = 0;
465 while i < chars.len() {
466 let c = chars[i];
467 match quote {
468 Some(q) => {
469 if c == q {
470 quote = None;
471 }
472 current.push(c);
473 }
474 None if c == '\'' || c == '"' => {
475 quote = Some(c);
476 current.push(c);
477 }
478 None if c == ';' || c == '\n' || c == '|' || c == '&' => {
479 out.push(std::mem::take(&mut current));
480 // `&&` and `||` are one separator.
481 if (c == '|' || c == '&') && chars.get(i + 1) == Some(&c) {
482 i += 1;
483 }
484 }
485 None if c == '(' || c == ')' || c == '`' || (c == '$' && chars.get(i + 1) == Some(&'(')) => {
486 // A subshell or a command substitution runs a command of
487 // its own.
488 out.push(std::mem::take(&mut current));
489 }
490 None => current.push(c),
491 }
492 i += 1;
493 }
494 out.push(current);
495 out.into_iter()
496 .map(|segment| segment.split_whitespace().collect::<Vec<_>>().join(" "))
497 .filter(|segment| !segment.is_empty())
498 .collect()
499}
500
501/// A command's words, with quotes removed, leading `VAR=value`
502/// assignments and wrappers such as `nohup` skipped.
503pub fn words(segment: &str) -> Vec<String> {
504 let mut out = Vec::new();
505 let mut current = String::new();
506 let mut quote: Option<char> = None;
507 let mut started = false;
508 for c in segment.chars() {
509 match quote {
510 Some(q) if c == q => quote = None,
511 Some(_) => current.push(c),
512 None if c == '\'' || c == '"' => {
513 quote = Some(c);
514 started = true;
515 }
516 None if c.is_whitespace() => {
517 if started || !current.is_empty() {
518 out.push(std::mem::take(&mut current));
519 started = false;
520 }
521 }
522 None if c == '{' || c == '}' => {}
523 None => current.push(c),
524 }
525 }
526 if started || !current.is_empty() {
527 out.push(current);
528 }
529 let skip = out
530 .iter()
531 .take_while(|word| {
532 is_assignment(word)
533 || matches!(word.as_str(), "nohup" | "time" | "exec" | "command" | "builtin" | "then" | "do" | "else" | "!")
534 })
535 .count();
536 out.split_off(skip)
537}
538
539fn is_assignment(word: &str) -> bool {
540 word.split_once('=').is_some_and(|(name, _)| {
541 !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
542 })
543}
544
545/// The name of a program, without its directory.
546fn program(word: &str) -> &str {
547 word.rsplit('/').next().unwrap_or(word)
548}
549
550/// git's subcommand and its arguments, past options such as `-C dir`.
551fn git_command(words: &[String]) -> Option<(&str, &[String])> {
552 if words.first().map(|word| program(word)) != Some("git") {
553 return None;
554 }
555 let mut i = 1;
556 while i < words.len() {
557 let word = words[i].as_str();
558 if word == "-C" || word == "-c" || word == "--git-dir" || word == "--work-tree" {
559 i += 2;
560 } else if word.starts_with('-') {
561 i += 1;
562 } else {
563 return Some((word, &words[i + 1..]));
564 }
565 }
566 None
567}
568
569/// Whether a variable's name looks like it holds a secret.
570fn secret_name(name: &str) -> bool {
571 let upper = name.to_uppercase();
572 ["TOKEN", "KEY", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "AUTH"]
573 .iter()
574 .any(|part| upper.contains(part))
575}
576
577/// Variables a command reads, by name: `$NAME` and `${NAME}`.
578fn variables(segment: &str) -> Vec<String> {
579 let mut out = Vec::new();
580 let chars: Vec<char> = segment.chars().collect();
581 let mut i = 0;
582 while i < chars.len() {
583 if chars[i] == '$' {
584 let mut j = i + 1;
585 if chars.get(j) == Some(&'{') {
586 j += 1;
587 }
588 let start = j;
589 while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') {
590 j += 1;
591 }
592 if j > start {
593 out.push(chars[start..j].iter().collect());
594 }
595 i = j;
596 } else {
597 i += 1;
598 }
599 }
600 out
601}
602
603/// The built-in rules, for one command of a shell line.
604fn builtin_shell(policy: &Policy, words: &[String], segment: &str, place: &Place) -> Option<String> {
605 let first = words.first().map(|word| program(word)).unwrap_or_default();
606 let args = words.get(1..).unwrap_or_default();
607
608 if policy.on("sudo") && matches!(first, "sudo" | "su" | "doas" | "pkexec") {
609 return Some("no sudo".to_owned());
610 }
611
612 if let Some((sub, rest)) = git_command(words) {
613 if sub == "push" {
614 let options: Vec<&str> = rest.iter().map(String::as_str).filter(|a| a.starts_with('-')).collect();
615 let positional: Vec<&str> = rest.iter().map(String::as_str).filter(|a| !a.starts_with('-')).collect();
616 let refspecs = positional.get(1..).unwrap_or_default();
617 if policy.on("force_push") {
618 let forced = options.iter().any(|option| {
619 matches!(*option, "--force" | "--mirror" | "--delete" | "--prune" | "--force-if-includes")
620 || option.starts_with("--force-with-lease")
621 || (!option.starts_with("--") && (option.contains('f') || option.contains('d')))
622 });
623 if forced || refspecs.iter().any(|spec| spec.starts_with('+') || spec.starts_with(':')) {
624 return Some("no force-pushing".to_owned());
625 }
626 }
627 if policy.on("rewrite_default_branch") {
628 let branch = place.default_branch.as_str();
629 let targets_default = refspecs.iter().any(|spec| {
630 let target = spec.rsplit(':').next().unwrap_or(spec).trim_start_matches('+');
631 target == branch || target == format!("refs/heads/{branch}")
632 });
633 if targets_default || options.contains(&"--all") {
634 return Some(format!("no pushing to {branch}, the default branch"));
635 }
636 }
637 }
638 if policy.on("rewrite_default_branch") {
639 let branch = place.default_branch.as_str();
640 let names_default = rest
641 .iter()
642 .any(|arg| arg == branch || *arg == format!("refs/heads/{branch}"));
643 let moves = rest
644 .iter()
645 .any(|arg| matches!(arg.as_str(), "-f" | "--force" | "-D" | "-d" | "--delete" | "-m" | "-M" | "--move"));
646 match sub {
647 "filter-branch" | "filter-repo" | "replace" => {
648 return Some("no rewriting history".to_owned());
649 }
650 "branch" if names_default && moves => {
651 return Some(format!("no moving or deleting {branch}, the default branch"));
652 }
653 "update-ref" if names_default => {
654 return Some(format!("no moving {branch}, the default branch"));
655 }
656 _ => {}
657 }
658 }
659 }
660
661 if policy.on("print_env") {
662 let prints = match first {
663 "printenv" => true,
664 // `env` alone, or with only options and assignments: no command.
665 "env" => args.iter().all(|arg| arg.starts_with('-') || is_assignment(arg)),
666 "export" | "declare" | "typeset" => args.is_empty() || args.iter().any(|arg| arg == "-p" || arg == "-x"),
667 "set" => args.is_empty(),
668 "compgen" => args.iter().any(|arg| arg == "-e" || arg == "-v"),
669 _ => false,
670 };
671 let environ = segment.contains("/proc/") && segment.contains("environ");
672 let secret = variables(segment).iter().any(|name| secret_name(name));
673 if prints || environ || secret {
674 return Some("no printing the environment".to_owned());
675 }
676 }
677
678 if policy.on("outside_workspace") {
679 let touched = protected_paths(place)
680 .into_iter()
681 .find(|path| segment.contains(path.as_str()));
682 if let Some(path) = touched {
683 return Some(format!("{path} is g1t's, not the project's"));
684 }
685 if segment.contains("/proc/") && segment.contains("environ") {
686 return Some("no reading other processes' environments".to_owned());
687 }
688 }
689 None
690}
691
692/// The tools a permission rule's tool name covers, as Claude Code reads
693/// them: an `Edit` rule covers every tool that writes a file.
694fn rule_covers(rule_tool: &str, tool: &str) -> bool {
695 match rule_tool {
696 "Edit" | "Write" => matches!(tool, "Edit" | "Write" | "MultiEdit" | "NotebookEdit"),
697 "Read" => matches!(tool, "Read" | "Glob" | "Grep" | "NotebookRead" | "LS"),
698 other => other == tool,
699 }
700}
701
702/// The workspace's own deny patterns.
703fn custom(policy: &Policy, tool: &str, input: &Value, place: &Place) -> Option<String> {
704 for pattern in &policy.deny {
705 let (rule_tool, spec) = match pattern.split_once('(') {
706 Some((name, rest)) => (name, rest.strip_suffix(')')),
707 None => (pattern.as_str(), None),
708 };
709 if !rule_covers(rule_tool, tool) {
710 continue;
711 }
712 let matched = match spec {
713 None => true,
714 Some(spec) if tool == "Bash" => {
715 let command = input.get("command").and_then(Value::as_str).unwrap_or_default();
716 segments(command).iter().any(|segment| shell_matches(spec, segment))
717 }
718 Some(spec) if rule_tool == "WebFetch" => {
719 let url = input.get("url").and_then(Value::as_str).unwrap_or_default();
720 let host = url.split("://").nth(1).unwrap_or(url).split(['/', ':']).next().unwrap_or_default();
721 let domain = spec.strip_prefix("domain:").unwrap_or(spec);
722 host == domain || host.ends_with(&format!(".{domain}"))
723 }
724 Some(spec) => match file_path(tool, input) {
725 Some(path) => {
726 let path = resolve(&path, place);
727 glob(&expand_rule_path(spec, place), &path)
728 }
729 None => false,
730 },
731 };
732 if matched {
733 return Some(format!("{pattern} is on this workspace's deny list"));
734 }
735 }
736 None
737}
738
739/// A rule's path made absolute, as Claude Code reads it: `//` is the root
740/// and `~/` the home directory; anything else is the project's.
741fn expand_rule_path(spec: &str, place: &Place) -> String {
742 if let Some(rest) = spec.strip_prefix("//") {
743 format!("/{rest}")
744 } else if let Some(rest) = spec.strip_prefix("~/") {
745 format!("{}/{rest}", place.home)
746 } else if let Some(rest) = spec.strip_prefix('/') {
747 format!("{}/{rest}", crate::WORKDIR)
748 } else {
749 format!("{}/{spec}", crate::WORKDIR)
750 }
751}
752
753/// Whether one command matches a `Bash(...)` rule: `prefix:*` for a
754/// command that starts with those words, `*` anywhere as a wildcard, or
755/// the exact command.
756pub fn shell_matches(spec: &str, segment: &str) -> bool {
757 let spec = spec.split_whitespace().collect::<Vec<_>>().join(" ");
758 let segment = words(segment).join(" ");
759 if let Some(prefix) = spec.strip_suffix(":*") {
760 return segment == prefix
761 || segment
762 .strip_prefix(prefix)
763 .is_some_and(|rest| rest.starts_with(' '));
764 }
765 if spec.contains('*') {
766 return wildcard(&spec, &segment, false);
767 }
768 segment == spec
769}
770
771/// A path glob: `**` crosses directories, `*` and `?` do not.
772pub fn glob(pattern: &str, path: &str) -> bool {
773 wildcard(pattern, path, true)
774}
775
776fn wildcard(pattern: &str, text: &str, paths: bool) -> bool {
777 let p: Vec<char> = pattern.chars().collect();
778 let t: Vec<char> = text.chars().collect();
779 fn go(p: &[char], t: &[char], paths: bool) -> bool {
780 match p.first() {
781 None => t.is_empty(),
782 Some('*') if paths && p.get(1) == Some(&'*') => {
783 let rest = if p.get(2) == Some(&'/') { &p[3..] } else { &p[2..] };
784 (0..=t.len()).any(|i| go(rest, &t[i..], paths))
785 || (p.get(2) == Some(&'/') && go(&p[2..], t, paths))
786 }
787 Some('*') => (0..=t.len())
788 .take_while(|i| !paths || *i == 0 || t[i - 1] != '/')
789 .any(|i| go(&p[1..], &t[i..], paths)),
790 Some('?') => !t.is_empty() && (!paths || t[0] != '/') && go(&p[1..], &t[1..], paths),
791 Some(c) => t.first() == Some(c) && go(&p[1..], &t[1..], paths),
792 }
793 }
794 go(&p, &t, paths)
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800
801 fn all_on() -> Policy {
802 Policy {
803 rules: ["force_push", "rewrite_default_branch", "outside_workspace", "print_env", "sudo"]
804 .into_iter()
805 .map(|rule| (rule.to_owned(), true))
806 .collect(),
807 ..Policy::default()
808 }
809 }
810
811 fn place() -> Place {
812 Place {
813 workdir: "/work/repo".to_owned(),
814 home: "/home/node".to_owned(),
815 default_branch: "main".to_owned(),
816 }
817 }
818
819 fn bash(policy: &Policy, command: &str) -> Option<String> {
820 decide(policy, "Bash", &json!({ "command": command }), &place())
821 }
822
823 #[test]
Reviews, plans and replies are written where the guardrail allows: g1t's answer files are inside it824 fn the_answer_files_may_be_written_and_nothing_else_beside_the_project() {
825 let policy = all_on();
826 let write = |path: &str| decide(&policy, "Write", &json!({ "file_path": path, "content": "{}" }), &place());
827 for path in ["/work/review.json", "/work/plan.json", "/work/answer.md", "/work/repo/src/lib.rs", "/tmp/x"] {
828 assert_eq!(write(path), None, "{path}");
829 }
830 for path in ["/work/notes.json", "/work/review.json.bak", "/etc/hosts"] {
831 assert!(write(path).is_some(), "{path}");
832 }
833 }
834
835 #[test]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API836 fn force_pushes_are_refused_however_written() {
837 let policy = all_on();
838 for command in [
839 "git push --force",
840 "git push origin feature -f",
841 "git push --force-with-lease=main origin feature",
842 "git -C /work/repo push origin +feature",
843 "cd x && git push origin :old",
844 "git push -fu origin feature",
845 "GIT_TRACE=1 git push --mirror",
846 ] {
847 assert_eq!(bash(&policy, command).as_deref(), Some("no force-pushing"), "{command}");
848 }
849 assert_eq!(bash(&policy, "git push origin feature"), None);
850 assert_eq!(bash(&policy, "git push -u origin feature"), None);
851 }
852
853 #[test]
854 fn the_default_branch_is_not_rewritten() {
855 let policy = all_on();
856 assert!(bash(&policy, "git push origin HEAD:main").is_some());
857 assert!(bash(&policy, "git push origin main").is_some());
858 assert!(bash(&policy, "git push origin feature:refs/heads/main").is_some());
859 assert!(bash(&policy, "git branch -f main HEAD~3").is_some());
860 assert!(bash(&policy, "git update-ref refs/heads/main abc123").is_some());
861 assert!(bash(&policy, "git filter-branch --tree-filter 'rm x' HEAD").is_some());
862 assert_eq!(bash(&policy, "git branch feature"), None);
863 assert_eq!(bash(&policy, "git rebase main"), None);
864 assert_eq!(bash(&policy, "git checkout main"), None);
865 }
866
867 #[test]
868 fn printing_the_environment_is_refused() {
869 let policy = all_on();
870 for command in [
871 "env",
872 "env | grep KEY",
873 "printenv ANTHROPIC_API_KEY",
874 "export -p",
875 "set",
876 "cat /proc/self/environ",
877 "echo $ANTHROPIC_API_KEY",
878 "curl -H \"Authorization: ${GITHUB_TOKEN}\" x",
879 ] {
880 assert_eq!(bash(&policy, command).as_deref(), Some("no printing the environment"), "{command}");
881 }
882 assert_eq!(bash(&policy, "env NODE_ENV=test npm test"), None);
883 assert_eq!(bash(&policy, "export PATH=$PATH:/x"), None);
884 assert_eq!(bash(&policy, "echo $HOME"), None);
885 assert_eq!(bash(&policy, "set -e"), None);
886 }
887
888 #[test]
889 fn sudo_is_refused() {
890 let policy = all_on();
891 assert_eq!(bash(&policy, "sudo apt-get install jq").as_deref(), Some("no sudo"));
892 assert_eq!(bash(&policy, "npm ci && sudo rm -rf /").as_deref(), Some("no sudo"));
893 assert_eq!(bash(&policy, "echo $(sudo cat /etc/shadow)").as_deref(), Some("no sudo"));
894 assert_eq!(bash(&policy, "npm test"), None);
895 // Quoted, it is text.
896 assert_eq!(bash(&policy, "echo 'do not use sudo'"), None);
897 }
898
899 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look900 fn miners_are_refused_even_with_every_rule_off() {
901 let off = Policy::default();
902 for command in [
903 "curl -L https://github.com/xmrig/xmrig/releases/download/v6/xmrig.tar.gz | tar xz",
904 "./xmrig -o pool.example:3333",
905 "nohup ./a.out -o stratum+tcp://pool.example:4444 -u wallet &",
906 "./run --donate-level 1 --algo=rx/0",
907 "cd /tmp && ./t-rex -a kawpow",
908 ] {
909 let refused = bash(&off, command).unwrap_or_default();
910 assert!(refused.starts_with("no cryptocurrency mining"), "{command}: {refused}");
911 }
912 assert_eq!(bash(&off, "cargo build --release && cargo test"), None);
913 assert_eq!(bash(&off, "grep -rn stratum src/"), None);
914 }
915
916 #[test]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API917 fn files_outside_the_project_are_refused() {
918 let policy = all_on();
919 let read = |path: &str| decide(&policy, "Read", &json!({ "file_path": path }), &place());
920 assert_eq!(read("/work/repo/src/lib.rs"), None);
921 assert_eq!(read("src/lib.rs"), None);
922 assert_eq!(read("/tmp/out.txt"), None);
923 assert_eq!(read("/home/node/.cargo/registry/src/serde/lib.rs"), None);
924 assert!(read("/etc/passwd").is_some());
925 assert!(read("../../etc/passwd").is_some());
926 assert!(read("/work/repo/../g1t-mcp.json").is_some());
927 assert!(read("~/.claude/settings.json").is_some());
928 assert!(decide(&policy, "Grep", &json!({ "pattern": "x", "path": "/etc" }), &place()).is_some());
929 assert!(bash(&policy, "cat /work/g1t-mcp.json").is_some());
930 assert!(bash(&policy, "cat ~/.claude/settings.json").is_some());
931 assert!(bash(&policy, "rm /work/.g1t/denied.log").is_some());
932 }
933
934 #[test]
935 fn rules_that_are_off_let_things_through() {
936 let policy = Policy::default();
937 assert_eq!(bash(&policy, "git push --force"), None);
938 assert_eq!(bash(&policy, "sudo true"), None);
939 assert_eq!(bash(&policy, "env"), None);
940 assert_eq!(decide(&policy, "Read", &json!({ "file_path": "/etc/passwd" }), &place()), None);
941 }
942
943 #[test]
944 fn custom_patterns_match_as_permission_rules_do() {
945 let policy = Policy {
946 deny: vec![
947 "Bash(terraform apply:*)".into(),
948 "Bash(rm -rf *)".into(),
949 "Edit(//etc/**)".into(),
950 "Read(secrets/**)".into(),
951 "WebFetch(domain:example.com)".into(),
952 "WebSearch".into(),
953 ],
954 ..Policy::default()
955 };
956 assert!(bash(&policy, "terraform apply -auto-approve").is_some());
957 assert!(bash(&policy, "cd infra && terraform apply").is_some());
958 assert_eq!(bash(&policy, "terraform applyx"), None);
959 assert_eq!(bash(&policy, "terraform plan"), None);
960 assert!(bash(&policy, "rm -rf build").is_some());
961 let write = |path: &str| decide(&policy, "Write", &json!({ "file_path": path }), &place());
962 assert!(write("/etc/hosts").is_some());
963 assert_eq!(write("/work/repo/etc/hosts"), None);
964 let read = |path: &str| decide(&policy, "Read", &json!({ "file_path": path }), &place());
965 assert!(read("secrets/prod/key.pem").is_some());
966 assert_eq!(read("src/secrets.rs"), None);
967 assert!(decide(&policy, "WebFetch", &json!({ "url": "https://docs.example.com/x" }), &place()).is_some());
968 assert_eq!(decide(&policy, "WebFetch", &json!({ "url": "https://example.org" }), &place()), None);
969 assert!(decide(&policy, "WebSearch", &json!({ "query": "x" }), &place()).is_some());
970 }
971
972 #[test]
973 fn commands_split_where_the_shell_does() {
974 assert_eq!(segments("a && b || c; d | e & f"), vec!["a", "b", "c", "d", "e", "f"]);
975 assert_eq!(segments("echo 'a; b' && c"), vec!["echo 'a; b'", "c"]);
976 assert_eq!(segments("x $(sudo y) `z`"), vec!["x", "sudo y", "z"]);
977 assert_eq!(words("FOO=1 nohup \"git\" push"), vec!["git", "push"]);
978 }
979
980 #[test]
981 fn globs_respect_directories() {
982 assert!(glob("/etc/**", "/etc/a/b"));
983 assert!(glob("/work/repo/**/*.pem", "/work/repo/a/b/key.pem"));
984 assert!(glob("/work/repo/**/*.pem", "/work/repo/key.pem"));
985 assert!(!glob("/work/repo/*.pem", "/work/repo/a/key.pem"));
986 assert!(glob("/work/repo/?.rs", "/work/repo/a.rs"));
987 }
988
989 #[test]
990 fn harness_settings_carry_the_hook_and_the_rules() {
991 let mut policy = all_on();
992 policy.deny = vec!["Bash(kubectl:*)".into()];
993 policy.restrict_network = true;
994 let settings = harness_settings(&policy);
995 let deny: Vec<&str> = settings["permissions"]["deny"]
996 .as_array()
997 .unwrap()
998 .iter()
999 .filter_map(Value::as_str)
1000 .collect();
1001 assert!(deny.contains(&"Bash(git push --force:*)"));
1002 assert!(deny.contains(&"Bash(sudo:*)"));
1003 assert!(deny.contains(&"Bash(kubectl:*)"));
1004 assert_eq!(
1005 settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
1006 "MODE=guard /usr/local/bin/g1t-runner"
1007 );
1008 assert_eq!(settings["env"]["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"], "1");
1009
1010 let open = harness_settings(&Policy::default());
1011 assert_eq!(open["permissions"]["deny"].as_array().unwrap().len(), 0);
1012 assert!(open.get("env").is_none());
1013 // The hook runs whatever the rules, for the workspace's own patterns.
1014 assert!(open["hooks"]["PreToolUse"].is_array());
1015 }
1016
1017 #[test]
1018 fn the_policy_reads_as_the_runner_sends_it() {
1019 let policy: Policy = serde_json::from_str(
1020 r#"{"rules":{"sudo":true,"print_env":false},"deny":["Bash(x:*)"],"budgetUsd":5,"minutes":90,"restrictNetwork":true,"defaultBranch":null}"#,
1021 )
1022 .unwrap();
1023 assert!(policy.on("sudo"));
1024 assert!(!policy.on("print_env"));
1025 assert_eq!(policy.budget_usd, Some(5.0));
1026 assert_eq!(policy.minutes, Some(90));
1027 assert!(policy.restrict_network);
1028 }
1029
1030 #[test]
1031 fn only_the_repositorys_own_checkout_is_trusted() {
1032 let repo = Some("acme/site");
1033 // The default branch or a branch of the repository itself.
1034 assert!(checkout_trusted(Some("https://g1t.sh/acme/site.git"), None, repo));
1035 assert!(checkout_trusted(
1036 Some("https://g1t.sh/Acme/Site"),
1037 Some("https://g1t.sh/acme/site.git"),
1038 None
1039 ));
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1040 // A fork's head, whoever's fork it is.
1041 assert!(!checkout_trusted(Some("https://g1t.sh/ana/site-12.git"), None, repo));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1042 assert!(!checkout_trusted(
1043 Some("https://g1t.sh/someone/site.git"),
1044 Some("https://g1t.sh/acme/site.git"),
1045 repo
1046 ));
1047 // Not knowing is not trusting.
1048 assert!(!checkout_trusted(Some("https://g1t.sh/acme/site.git"), None, None));
1049 assert!(!checkout_trusted(None, None, repo));
1050 // A look-alike name is another repository.
1051 assert!(!checkout_trusted(Some("https://g1t.sh/acme/site-evil.git"), None, repo));
1052 }
1053
1054 #[test]
1055 fn untrusted_checkouts_load_nothing_of_their_own() {
1056 let flags = UNTRUSTED_FLAGS.join(" ");
1057 assert!(flags.contains("--setting-sources user"));
1058 assert!(!flags.contains("project") && !flags.contains("local"));
1059 assert!(flags.contains("--strict-mcp-config"));
1060 assert!(flags.contains("--disable-slash-commands"));
1061 assert_eq!(UNTRUSTED_ENV, ("CLAUDE_CODE_DISABLE_CLAUDE_MDS", "1"));
1062 }
1063
1064 #[test]
1065 fn halts_are_told_apart_from_failures() {
1066 assert!(is_halt(&anyhow::Error::new(Halted::Budget)));
1067 assert!(!is_halt(&anyhow::anyhow!("the agent reported an error")));
1068 assert_eq!(Halted::Time.reason(), "time");
1069 }
1070}