a73x

23a19b89

Delegates write refs/collab/* and nothing else

a73x   2026-08-18 17:17

Commit message
Delegates write refs/collab/* and nothing else

The hard-coded ceiling: the update hook reads GIT_COLLAB_DELEGATE
(set alongside GIT_COLLAB_PRINCIPAL whenever the session is a
certificate) and, before consulting any access.conf rule, refuses any
refname outside refs/collab/*. No line of configuration can widen it.

Two more sites needed their own denial, because the hook only sees
ref updates, not the requests that precede them: exec denies
Access::Create to delegates before consulting the person's C rule (so
CREATOR can never resolve to a delegate), and handle_release_command
denies anything but List (upload/delete need Access::Rewind, which is
a branch-shaped permission, not a collab-refs one).

Regime::Governed::delegate now has a reader, so the #[allow(dead_code)]
from the previous task comes off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/server/governance/hook.rs
Old New
@@ -26,6 +26,9 @@ use super::{creator_of, load, validate_settings_tree, GovernanceState, SETTINGS_
26 /// back. Named rather than inherited so that a hook running outside the server 26 /// back. Named rather than inherited so that a hook running outside the server
27 /// — by hand, say — fails closed instead of guessing. 27 /// — by hand, say — fails closed instead of guessing.
28 pub const ENV_PRINCIPAL: &str = "GIT_COLLAB_PRINCIPAL"; 28 pub const ENV_PRINCIPAL: &str = "GIT_COLLAB_PRINCIPAL";
29 /// Set only when the pushing session is a delegate certificate; its value is
30 /// the cert's key ID. Presence is what puts the collab-refs ceiling in force.
31 pub const ENV_DELEGATE: &str = "GIT_COLLAB_DELEGATE";
29 pub const ENV_REPOS_DIR: &str = "GIT_COLLAB_REPOS_DIR"; 32 pub const ENV_REPOS_DIR: &str = "GIT_COLLAB_REPOS_DIR";
30 pub const ENV_REPO: &str = "GIT_COLLAB_REPO"; 33 pub const ENV_REPO: &str = "GIT_COLLAB_REPO";
31 pub const ENV_REPO_PATH: &str = "GIT_COLLAB_REPO_PATH"; 34 pub const ENV_REPO_PATH: &str = "GIT_COLLAB_REPO_PATH";
@@ -135,6 +138,18 @@ pub fn run(refname: &str, old: &str, new: &str) -> Result<(), String> {
135 if principal.is_empty() { 138 if principal.is_empty() {
136 return Err("no authenticated principal on this push".to_string()); 139 return Err("no authenticated principal on this push".to_string());
137 } 140 }
141 // The delegate ceiling. Hard-coded rather than configured: no line in
142 // access.conf can widen what a certificate may write.
143 let delegate = std::env::var(ENV_DELEGATE).ok().filter(|v| !v.is_empty());
144 if let Some(key_id) = &delegate {
145 if !refname.starts_with("refs/collab/") {
146 return Err(format!(
147 "delegate {key_id} of {principal} may only write refs/collab/*, \
148 not {refname}"
149 ));
150 }
151 }
152
138 let creator = creator_of(&repo_path); 153 let creator = creator_of(&repo_path);
139 let subject = Subject::with_creator(&principal, creator.as_deref()); 154 let subject = Subject::with_creator(&principal, creator.as_deref());
140 let access = required_access(&repo, old, new); 155 let access = required_access(&repo, old, new);
src/server/ssh/session.rs
Old New
@@ -29,9 +29,6 @@ enum Regime {
29 name: String, 29 name: String,
30 /// `Some(key_id)` when this session is a certificate, acting for 30 /// `Some(key_id)` when this session is a certificate, acting for
31 /// `name` under the hard-coded collab-refs ceiling. 31 /// `name` under the hard-coded collab-refs ceiling.
32 ///
33 /// Not read yet: the ceiling itself lands in a follow-up task.
34 #[allow(dead_code)]
35 delegate: Option<String>, 32 delegate: Option<String>,
36 }, 33 },
37 /// Either the settings repository could not be read, or this connection's 34 /// Either the settings repository could not be read, or this connection's
@@ -195,8 +192,11 @@ impl SshHandler {
195 repo_path.to_string_lossy().into_owned(), 192 repo_path.to_string_lossy().into_owned(),
196 ), 193 ),
197 ]; 194 ];
198 if let Regime::Governed { name, .. } = regime { 195 if let Regime::Governed { name, delegate, .. } = regime {
199 env.push((governance::hook::ENV_PRINCIPAL.to_string(), name.clone())); 196 env.push((governance::hook::ENV_PRINCIPAL.to_string(), name.clone()));
197 if let Some(key_id) = delegate {
198 env.push((governance::hook::ENV_DELEGATE.to_string(), key_id.clone()));
199 }
200 } 200 }
201 Ok(env) 201 Ok(env)
202 } 202 }
@@ -245,6 +245,18 @@ impl SshHandler {
245 ReleaseCmd::List { .. } => Access::Read, 245 ReleaseCmd::List { .. } => Access::Read,
246 ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => Access::Rewind, 246 ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => Access::Rewind,
247 }; 247 };
248 // Artifacts are not collab refs. A delegate may list what its person
249 // may see; publishing and deleting are outside the ceiling.
250 if let Regime::Governed {
251 delegate: Some(key_id),
252 ..
253 } = regime
254 {
255 if needed != Access::Read {
256 warn!("Rejected release command from delegate {key_id}");
257 return reply_and_close(session, channel, NOT_FOUND, 1);
258 }
259 }
248 let authorized = match regime { 260 let authorized = match regime {
249 Regime::Closed => false, 261 Regime::Closed => false,
250 Regime::Ungoverned => match needed { 262 Regime::Ungoverned => match needed {
@@ -807,14 +819,17 @@ impl Handler for SshHandler {
807 // allocator, so under governance it is a permission of its own: 819 // allocator, so under governance it is a permission of its own:
808 // `C` on a pattern the requested name matches. 820 // `C` on a pattern the requested name matches.
809 if let Regime::Governed { 821 if let Regime::Governed {
810 governance, name, .. 822 governance,
823 name,
824 delegate,
811 } = &regime 825 } = &regime
812 { 826 {
813 let allowed = repo_key.as_deref().is_some_and(|key| { 827 let allowed = delegate.is_none()
814 governance 828 && repo_key.as_deref().is_some_and(|key| {
815 .conf 829 governance
816 .allows_repo(key, &Subject::new(name), Access::Create) 830 .conf
817 }); 831 .allows_repo(key, &Subject::new(name), Access::Create)
832 });
818 if !allowed { 833 if !allowed {
819 warn!( 834 warn!(
820 "Rejected exec request: principal {} may not create {:?}", 835 "Rejected exec request: principal {} may not create {:?}",
tests/delegate_test.rs
Old New
@@ -94,3 +94,110 @@ fn a_ca_enrolled_for_another_name_is_rejected() {
94 "a CA enrolled for bob minted a working delegate of alex" 94 "a CA enrolled for bob minted a working delegate of alex"
95 ); 95 );
96 } 96 }
97
98 /// The ceiling: a delegate writes collab refs with the person's authority —
99 /// and cannot move a branch the person holds RW+ on.
100 #[test]
101 fn a_delegate_writes_collab_refs_and_may_not_write_branches() {
102 let harness = ServerHarness::new("delegate-ceiling");
103 harness.push_head();
104 harness.bootstrap_settings_with_cas(
105 &access_conf(harness.repo_name()),
106 &[("alex.pub", "alex")],
107 &[("mint/alex.pub", "mint")],
108 );
109
110 let agent_key = harness.named_key("agent-key");
111 let ca = harness.delegate_ca("mint");
112 let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m");
113
114 harness.work_repo().issue_open("Filed by a delegate");
115 let push = harness.ssh_push_from_cert(
116 harness.work_repo().dir.path(),
117 &agent_key,
118 &cert,
119 harness.repo_name(),
120 "refs/collab/*:refs/collab/*",
121 );
122 assert!(
123 push.status.success(),
124 "delegate collab push failed: {}",
125 stderr(&push)
126 );
127
128 harness
129 .work_repo()
130 .commit_file("d.txt", "delegate", "delegate commit");
131 let push = harness.ssh_push_from_cert(
132 harness.work_repo().dir.path(),
133 &agent_key,
134 &cert,
135 harness.repo_name(),
136 "main:main",
137 );
138 assert!(
139 !push.status.success(),
140 "a delegate moved a branch; alex holds RW+ but the cert must not inherit it"
141 );
142 assert!(
143 stderr(&push).contains("refs/collab"),
144 "the refusal should name the ceiling, got: {}",
145 stderr(&push)
146 );
147 }
148
149 /// The person's own key is untouched by the ceiling.
150 #[test]
151 fn the_person_still_writes_branches_directly() {
152 let harness = ServerHarness::new("delegate-person-unaffected");
153 harness.push_head();
154 harness.bootstrap_settings_with_cas(
155 &access_conf(harness.repo_name()),
156 &[("alex.pub", "alex")],
157 &[("mint/alex.pub", "mint")],
158 );
159 harness
160 .work_repo()
161 .commit_file("p.txt", "person", "person commit");
162 let push = harness.ssh_push(&harness.named_key("alex"), "main:main");
163 assert!(
164 push.status.success(),
165 "the person's own push failed: {}",
166 stderr(&push)
167 );
168 }
169
170 /// Creation is a permission delegates never hold, so CREATOR can never
171 /// resolve to one.
172 #[test]
173 fn a_delegate_may_not_create_a_repository_its_person_could() {
174 let harness = ServerHarness::new("delegate-create");
175 harness.push_head();
176 let conf = format!(
177 "repo settings\n RW+ = alex\n\nrepo {}\n RW+ = alex\n\nrepo agents/[a-z-]+\n C = alex\n RW+ = CREATOR\n",
178 harness.repo_name()
179 );
180 harness.bootstrap_settings_with_cas(
181 &conf,
182 &[("alex.pub", "alex")],
183 &[("mint/alex.pub", "mint")],
184 );
185
186 let agent_key = harness.named_key("agent-key");
187 let ca = harness.delegate_ca("mint");
188 let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m");
189
190 harness.work_repo().commit_file("n.txt", "new", "seed");
191 let push = harness.ssh_push_from_cert(
192 harness.work_repo().dir.path(),
193 &agent_key,
194 &cert,
195 "agents/claude-a",
196 "main:main",
197 );
198 assert!(!push.status.success(), "a delegate created a repository");
199 assert!(
200 !harness.repos_dir().join("agents/claude-a.git").exists(),
201 "the repository must not exist after a refused create"
202 );
203 }