1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
//! Hardened `git` invocation — the safety primitives shared by core git spawn
//! sites ([`crate::git`], `tracker::git`, [`crate::message`]) and the delivery
//! plugin's stricter local-only boundary ([`delivery_at`], bl-1ec6).
//!
//! balls always targets an explicit checkout via `-C <cwd>`, so the `GIT_*`
//! variables that REDIRECT which repository / object-store / index git operates
//! on can only ever MISDIRECT it — a silent wrong-repo when balls runs inside an
//! ambient git context (a hook, a parent that exported `GIT_DIR`), or an
//! attacker's hijack of the process environment, which bypasses the §4
//! config-is-RCE consent boundary entirely. [`at`] strips that family before
//! every spawn. What it deliberately PRESERVES is the auth/identity inheritance a
//! legitimate fetch/push needs and which is indistinguishable from user intent:
//! `SSH_AUTH_SOCK`, `HOME` (→ `~/.gitconfig` identity + credential helpers),
//! `GIT_SSH_COMMAND`, and the proxy vars. A blanket `env_clear()` would break all
//! of those user stories; this strips only the redirection vectors.
//!
//! Two further guards close the remote-string RCE the §4 model understates:
//! `protocol.ext.allow=never` forbids git's `ext::sh -c …` transport (arbitrary
//! command execution from a remote URL), and [`reject_option_like`] refuses a
//! remote/refspec beginning with `-` so a config-sourced value cannot smuggle an
//! option (`--upload-pack=<cmd>` is itself RCE) into `fetch`/`push`.
//!
//! Delivery has no remote/auth use-case, so it can be stricter. Its environment
//! is rebuilt by [`delivery_env`]: ordinary non-`GIT_*` execution variables are
//! retained for git and project hooks, but the only inherited `GIT_*` values are
//! author/committer identity and time. Global/system config is disabled and
//! every other Git control variable is absent, including unbounded indexed
//! `GIT_CONFIG_KEY_N` / `GIT_CONFIG_VALUE_N` input and repository redirects.
//! Repository-local/worktree config still loads; it is the delivery authority.
use std::ffi::OsStr;
use std::io;
use std::path::Path;
use std::process::Command;
/// `GIT_*` vars that redirect git's repo / object-store / index. balls never
/// needs one (it always passes `-C <cwd>`), so each is stripped before a spawn.
const REDIRECT_VARS: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_COMMON_DIR",
"GIT_NAMESPACE",
];
/// The complete inherited `GIT_*` allowlist for local delivery. Core exports
/// the dates as the operation instant; explicit identity is legitimate author
/// input. No Git behavior/configuration variable is needed.
const DELIVERY_IDENTITY_VARS: &[&str] = &[
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_AUTHOR_DATE",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
"GIT_COMMITTER_DATE",
];
#[cfg(unix)]
const NULL_GIT_CONFIG: &str = "/dev/null";
#[cfg(windows)]
const NULL_GIT_CONFIG: &str = "NUL";
/// A `git` [`Command`] rooted at `cwd`, hardened: the [`REDIRECT_VARS`] stripped
/// from the inherited environment and the `ext::` shell transport denied. The
/// caller appends the subcommand and its args.
#[must_use]
pub(crate) fn at(cwd: &Path) -> Command {
let mut cmd = Command::new("git");
for var in REDIRECT_VARS {
cmd.env_remove(var);
}
cmd.arg("-c").arg("protocol.ext.allow=never").arg("-C").arg(cwd);
cmd
}
/// A local-delivery `git` command rooted at `cwd`. Unlike [`at`], this rebuilds
/// the process environment through [`delivery_env`]; delivery never needs
/// ambient Git config, repository selection, object storage, or auth controls.
#[must_use]
pub(crate) fn delivery_at(cwd: &Path) -> Command {
let mut cmd = Command::new("git");
delivery_env(&mut cmd);
cmd.arg("-c")
.arg("protocol.ext.allow=never")
.arg("-C")
.arg(cwd);
cmd
}
/// Rebuild `cmd`'s environment for delivery Git and the manually executed
/// pre-commit gate. Keeping all non-`GIT_*` values preserves executable lookup
/// and hook toolchains. Inside Git's namespace, only identity/time crosses the
/// boundary; safe config-search policy is then supplied explicitly. Thus the
/// local/worktree repo config remains active while system, global, command-env,
/// indexed, redirect, discovery, object/index and execution overrides cannot
/// arrive from the caller. The prefix test is case-insensitive for Windows.
pub(crate) fn delivery_env(cmd: &mut Command) {
cmd.env_clear()
.envs(std::env::vars_os().filter(|(key, _)| delivery_variable(key)))
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", NULL_GIT_CONFIG)
.env("GIT_ATTR_NOSYSTEM", "1");
}
/// Whether an ambient variable may cross the delivery boundary.
fn delivery_variable(key: &OsStr) -> bool {
let bytes = key.as_encoded_bytes();
let is_git = bytes
.get(..4)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"GIT_"));
!is_git
|| DELIVERY_IDENTITY_VARS
.iter()
.any(|allowed| bytes.eq_ignore_ascii_case(allowed.as_bytes()))
}
/// Refuse an untrusted positional (a remote URL or a refspec/branch) that begins
/// with `-`: git would parse it as an option, not a value, and `--upload-pack=…`
/// turns that into command execution. The guard the tracker applies to its
/// config-sourced `remote` and `tasks_branch` before `fetch`/`push`.
pub(crate) fn reject_option_like(value: &str) -> io::Result<()> {
if value.starts_with('-') {
return Err(io::Error::other(format!(
"refusing git argument that looks like an option: {value:?}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn at_strips_every_redirect_var() {
// `Command::get_envs` reports an `env_remove` as `(key, None)`; assert each
// redirection var is scheduled for removal — no process-env mutation, so
// this is race-free (the bl-bfa8/bl-ad4b lesson).
let cmd = at(Path::new("/proj"));
let removed: Vec<String> = cmd
.get_envs()
.filter(|(_, v)| v.is_none())
.map(|(k, _)| k.to_string_lossy().into_owned())
.collect();
for var in REDIRECT_VARS {
assert!(removed.iter().any(|k| k == var), "{var} not stripped");
}
}
#[test]
fn at_denies_ext_transport_and_targets_cwd() {
let cmd = at(Path::new("/proj"));
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert!(args.windows(2).any(|w| w == ["-c", "protocol.ext.allow=never"]));
let dash_c = args.iter().position(|a| a == "-C").expect("-C is set");
assert_eq!(args[dash_c + 1], "/proj");
}
#[test]
fn delivery_filter_admits_only_identity_from_the_git_namespace() {
for allowed in DELIVERY_IDENTITY_VARS {
assert!(
delivery_variable(OsStr::new(allowed)),
"{allowed} not retained"
);
}
for hostile in [
"GIT_CONFIG_COUNT",
"GIT_CONFIG_KEY_999999",
"GIT_CONFIG_VALUE_999999",
"GIT_CONFIG_PARAMETERS",
"GIT_CONFIG_GLOBAL",
"GIT_DIR",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_CEILING_DIRECTORIES",
"GIT_DISCOVERY_ACROSS_FILESYSTEM",
"GIT_EXEC_PATH",
"GIT_TEMPLATE_DIR",
"git_work_tree",
] {
assert!(!delivery_variable(OsStr::new(hostile)), "{hostile} leaked");
}
assert!(delivery_variable(OsStr::new("PATH")));
assert!(delivery_variable(OsStr::new("HOME")));
}
#[test]
fn delivery_command_replaces_prior_git_controls_with_safe_policy() {
let mut cmd = Command::new("git");
cmd.env("GIT_CONFIG_COUNT", "1")
.env("GIT_DIR", "/elsewhere");
delivery_env(&mut cmd);
let envs: Vec<(String, Option<String>)> = cmd
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().into_owned(),
value.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect();
assert!(!envs
.iter()
.any(|(key, _)| key == "GIT_CONFIG_COUNT" || key == "GIT_DIR"));
assert!(envs.contains(&("GIT_CONFIG_NOSYSTEM".into(), Some("1".into()))));
assert!(envs.contains(&("GIT_CONFIG_GLOBAL".into(), Some(NULL_GIT_CONFIG.into()))));
assert!(envs.contains(&("GIT_ATTR_NOSYSTEM".into(), Some("1".into()))));
let cmd = delivery_at(Path::new("/project"));
let args: Vec<String> = cmd
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert!(args
.windows(2)
.any(|pair| pair == ["-c", "protocol.ext.allow=never"]));
assert!(args.windows(2).any(|pair| pair == ["-C", "/project"]));
}
#[test]
fn ext_transport_is_actually_refused_at_runtime() {
// The behavioural half: a built command really rejects an `ext::` remote.
let out = at(Path::new("/")).args(["ls-remote", "ext::sh -c id"]).output().unwrap();
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("ext"), "git refused the ext transport: {err}");
}
#[test]
fn reject_option_like_blocks_dash_values_only() {
assert!(reject_option_like("--upload-pack=evil").is_err());
assert!(reject_option_like("-x").is_err());
assert!(reject_option_like("origin").is_ok());
assert!(reject_option_like("git@github.com:o/r.git").is_ok());
assert!(reject_option_like("balls/tasks").is_ok());
}
}