1use std::collections::HashMap;
26use std::ffi::OsString;
27use std::path::{Path, PathBuf};
28use std::process::Command;
29use std::time::Duration;
30
31pub const TOKEN_ENV: &str = "SGIAN_CLIENT_TOKEN";
33pub const BIN_ENV: &str = "KRANZ_SGIAN_BIN";
35pub const DEADLINE: Duration = Duration::from_secs(5);
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SgianCredential {
43 pub id: String,
45 pub holder: String,
47 bin: PathBuf,
48 workspace: PathBuf,
49}
50
51pub(crate) struct RevocationGuard(Option<SgianCredential>);
53
54impl RevocationGuard {
55 pub(crate) async fn close(mut self) {
56 if let Some(credential) = self.0.take() {
57 if let Err(error) = tokio::task::spawn_blocking(move || credential.revoke()).await {
58 tracing::warn!(%error, "sgian revocation task failed; operator reconciliation required");
59 }
60 }
61 }
62}
63
64impl Drop for RevocationGuard {
65 fn drop(&mut self) {
66 if let Some(credential) = self.0.take() {
67 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
70 runtime.spawn_blocking(move || credential.revoke());
71 } else if let Err(error) = std::thread::Builder::new()
72 .name("kranz-sgian-revoke".into())
73 .spawn(move || credential.revoke())
74 {
75 tracing::warn!(%error, "sgian revocation could not start; operator reconciliation required");
76 }
77 }
78 }
79}
80
81pub(crate) async fn issue_worker(
84 workspace: &Path,
85 session_cwd: &Path,
86 run_id: &str,
87 enforce: crate::types::SandboxEnforce,
88) -> Option<(RevocationGuard, String)> {
89 if enforce != crate::types::SandboxEnforce::Off {
90 return None;
91 }
92 let bin = trusted_bin(std::env::var_os(BIN_ENV), workspace)?;
93 if bin.starts_with(session_cwd.canonicalize().ok()?) {
94 return None;
95 }
96 let workspace = workspace.to_path_buf();
97 let run_id = run_id.to_string();
98 tokio::task::spawn_blocking(move || {
99 issue_with(&bin, &workspace, &run_id, DEADLINE)
100 .map(|(credential, token)| (RevocationGuard(Some(credential)), token))
101 })
102 .await
103 .ok()
104 .flatten()
105}
106
107pub fn holder_for(run_id: &str) -> String {
109 format!("kranz:{run_id}")
110}
111
112pub fn issue(workspace: &Path, run_id: &str) -> Option<(SgianCredential, String)> {
116 let bin = trusted_bin(std::env::var_os(BIN_ENV), workspace)?;
117 issue_with(&bin, workspace, run_id, DEADLINE)
118}
119
120pub fn issue_with(
122 bin: &Path,
123 workspace: &Path,
124 run_id: &str,
125 deadline: Duration,
126) -> Option<(SgianCredential, String)> {
127 let holder = holder_for(run_id);
128 let args = [
129 "ctl",
130 "--workspace",
131 &workspace.display().to_string(),
132 "--json",
133 "identity",
134 "issue",
135 "--holder",
136 &holder,
137 "--scope",
138 "write",
139 ];
140 let output = match run_ctl(bin, &args, deadline) {
141 Ok(output) => output,
142 Err(reason) => {
143 tracing::debug!(
144 run_id,
145 workspace = %workspace.display(),
146 reason,
147 "sgian credential not issued; the session runs without one"
148 );
149 return None;
150 }
151 };
152 let record: serde_json::Value = match serde_json::from_slice(&output) {
153 Ok(record) => record,
154 Err(error) => {
155 tracing::warn!(
156 run_id,
157 error = %error,
158 "sgian identity issue replied with something other than a credential record"
159 );
160 return None;
161 }
162 };
163 let id = record.get("id").and_then(serde_json::Value::as_str);
164 let issued = record.get("token").and_then(serde_json::Value::as_str);
165 let (Some(id), Some(token)) = (id, issued) else {
166 tracing::warn!(
167 run_id,
168 "sgian identity issue record lacks an id or token; ignoring it"
169 );
170 return None;
171 };
172 if id.is_empty() || token.is_empty() {
173 return None;
174 }
175 tracing::info!(
176 run_id,
177 id,
178 holder = %holder,
179 "sgian credential issued for the run (revoked when the run ends)"
180 );
181 Some((
182 SgianCredential {
183 id: id.to_string(),
184 holder,
185 bin: bin.to_path_buf(),
186 workspace: workspace.to_path_buf(),
187 },
188 token.to_string(),
189 ))
190}
191
192impl SgianCredential {
193 pub fn revoke(&self) {
196 self.revoke_with(DEADLINE);
197 }
198
199 pub fn revoke_with(&self, deadline: Duration) {
201 let args = [
202 "ctl",
203 "--workspace",
204 &self.workspace.display().to_string(),
205 "--json",
206 "identity",
207 "revoke",
208 &self.id,
209 ];
210 match run_ctl(&self.bin, &args, deadline) {
211 Ok(_) => tracing::info!(
212 id = %self.id,
213 holder = %self.holder,
214 "sgian credential revoked"
215 ),
216 Err(reason) => tracing::warn!(
217 id = %self.id,
218 holder = %self.holder,
219 reason,
220 "sgian credential could not be revoked; revoke it by hand with \
221 `sgian ctl identity revoke` if the daemon is still running"
222 ),
223 }
224 }
225}
226
227pub fn resolve_bin(override_var: Option<OsString>, _path_var: Option<OsString>) -> Option<PathBuf> {
230 let path = PathBuf::from(override_var?);
231 path.is_absolute().then_some(path)
232}
233
234fn trusted_bin(override_var: Option<OsString>, workspace: &Path) -> Option<PathBuf> {
235 let bin = resolve_bin(override_var, None)?.canonicalize().ok()?;
236 let workspace = workspace.canonicalize().ok()?;
237 (bin.is_file() && !bin.starts_with(workspace)).then_some(bin)
240}
241
242fn run_ctl(bin: &Path, args: &[&str], deadline: Duration) -> Result<Vec<u8>, String> {
246 let mut command = Command::new(bin);
247 command
248 .args(args)
249 .env_clear()
250 .envs(crate::agent_env::probe_child_env(&[]));
251 let output = crate::git_ops::process::output(
252 command,
253 crate::git_ops::process::Limits::for_control(deadline),
254 )
255 .map_err(|error| format!("{} control call failed: {error}", bin.display()))?;
256 if output.status.success() {
257 Ok(output.stdout)
258 } else {
259 Err(format!("{} exited with {}", bin.display(), output.status))
260 }
261}
262
263pub fn seed_env(env: &mut HashMap<String, String>, token: String) {
266 env.insert(TOKEN_ENV.to_string(), token);
267}
268
269#[cfg(all(test, unix))]
270mod tests {
271 use super::*;
272 use std::os::unix::fs::PermissionsExt;
273 use std::time::Instant;
274
275 fn fake_sgian(dir: &Path, body: &str) -> PathBuf {
278 let bin = dir.join("sgian");
279 let calls = dir.join("calls");
280 std::fs::write(
281 &bin,
282 format!(
283 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n{body}\n",
284 calls.display()
285 ),
286 )
287 .unwrap();
288 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
289 bin
290 }
291
292 fn calls(dir: &Path) -> Vec<String> {
293 std::fs::read_to_string(dir.join("calls"))
294 .unwrap_or_default()
295 .lines()
296 .map(str::to_string)
297 .collect()
298 }
299
300 #[test]
301 fn issue_parses_the_record_and_revoke_uses_its_id() {
302 let dir = tempfile::tempdir().unwrap();
303 let ws = dir.path().join("repo");
304 std::fs::create_dir_all(&ws).unwrap();
305 let bin = fake_sgian(
306 dir.path(),
307 r#"case "$*" in *"identity issue"*) printf '{"id":"cred-7","holder":"kranz:run-1","scopes":["write"],"token":"sgc_t"}\n';; *) printf '{"id":"cred-7","revoked":true}\n';; esac"#,
308 );
309 let (cred, token) = issue_with(&bin, &ws, "run-1", DEADLINE).expect("credential issued");
313 assert_eq!(cred.id, "cred-7");
314 assert_eq!(cred.holder, "kranz:run-1");
315 assert_eq!(token, "sgc_t");
316 cred.revoke();
317 let calls = calls(dir.path());
318 assert_eq!(
319 calls,
320 vec![
321 format!(
322 "ctl --workspace {} --json identity issue --holder kranz:run-1 --scope write",
323 ws.display()
324 ),
325 format!(
326 "ctl --workspace {} --json identity revoke cred-7",
327 ws.display()
328 ),
329 ]
330 );
331 }
332
333 #[test]
334 fn issue_is_none_when_ctl_fails_or_answers_nonsense() {
335 let dir = tempfile::tempdir().unwrap();
336 let ws = dir.path().to_path_buf();
337 let failing = fake_sgian(
338 dir.path(),
339 "echo 'no daemon serves this workspace' >&2; exit 1",
340 );
341 assert!(issue_with(&failing, &ws, "run-2", DEADLINE).is_none());
342 let garbage = fake_sgian(dir.path(), "echo 'not json'");
343 assert!(issue_with(&garbage, &ws, "run-2", DEADLINE).is_none());
344 let partial = fake_sgian(dir.path(), r#"printf '{"id":"x"}\n'"#);
345 assert!(issue_with(&partial, &ws, "run-2", DEADLINE).is_none());
346 }
347
348 #[test]
349 fn issue_gives_up_on_a_wedged_client() {
350 let dir = tempfile::tempdir().unwrap();
351 let bin = fake_sgian(dir.path(), "sleep 5");
352 let started = Instant::now();
353 assert!(issue_with(&bin, dir.path(), "run-3", Duration::from_millis(200)).is_none());
354 assert!(
355 started.elapsed() < Duration::from_secs(3),
356 "the deadline must cut the wait short"
357 );
358 }
359
360 #[test]
361 fn sgian_control_deadline_covers_pipes_after_leader_exit() {
362 let dir = tempfile::tempdir().unwrap();
363 let marker = dir.path().join("descendant-ran");
364 let bin = fake_sgian(
365 dir.path(),
366 &format!("(sleep 1; touch '{}') & exit 0", marker.display()),
367 );
368 assert!(run_ctl(&bin, &[], Duration::from_secs(3)).is_ok());
370 assert!(marker.exists());
371 std::fs::remove_file(&marker).unwrap();
372 let started = Instant::now();
373 assert!(run_ctl(&bin, &[], Duration::from_millis(100)).is_err());
374 assert!(started.elapsed() < Duration::from_secs(3));
375 std::thread::sleep(Duration::from_millis(1200));
376 assert!(!marker.exists(), "timed-out descendants must not continue");
377 }
378
379 #[test]
380 fn sgian_control_refuses_oversize_output_and_does_not_log_reply_bytes() {
381 let dir = tempfile::tempdir().unwrap();
382 for stream in ["", " >&2"] {
383 let bin = fake_sgian(dir.path(), &format!("head -c 65537 /dev/zero{stream}"));
384 assert!(run_ctl(&bin, &[], DEADLINE).is_err());
385 }
386 let bin = fake_sgian(dir.path(), "echo 'private-reply' >&2; exit 1");
387 let error = run_ctl(&bin, &[], DEADLINE).unwrap_err();
388 assert!(!error.contains("private-reply"));
389 }
390
391 #[test]
392 fn sgian_control_keeps_discovery_environment_without_ambient_secrets() {
393 let name =
394 "sgian::tests::sgian_control_keeps_discovery_environment_without_ambient_secrets";
395 if crate::agent_env::isolated_global_home_test(name) {
396 return;
397 }
398 std::env::set_var("KRANZ_SGIAN_PRIVATE_FIXTURE", "private-value");
399 std::env::set_var(TOKEN_ENV, "private-value");
400 let dir = tempfile::tempdir().unwrap();
401 let bin = fake_sgian(dir.path(), "env");
402 let output = run_ctl(&bin, &[], DEADLINE).unwrap();
403 let env = String::from_utf8(output).unwrap();
404 assert!(env.lines().any(|line| line.starts_with("HOME=")));
405 assert!(env.lines().any(|line| line.starts_with("PATH=")));
406 assert!(!env.contains("KRANZ_SGIAN_PRIVATE_FIXTURE="));
407 assert!(!env.contains("SGIAN_CLIENT_TOKEN="));
408 }
409
410 #[test]
411 fn issue_is_none_when_the_binary_is_missing() {
412 let dir = tempfile::tempdir().unwrap();
413 assert!(issue_with(&dir.path().join("absent"), dir.path(), "run-4", DEADLINE).is_none());
414 }
415
416 #[test]
417 fn resolve_bin_requires_explicit_absolute_opt_in() {
418 let dir = tempfile::tempdir().unwrap();
419 let bin = fake_sgian(dir.path(), "true");
420 assert_eq!(
421 resolve_bin(Some(OsString::from("/opt/sgian")), None),
422 Some(PathBuf::from("/opt/sgian"))
423 );
424 assert_eq!(
425 resolve_bin(Some(OsString::new()), Some(dir.path().into())),
426 None
427 );
428 let path =
429 std::env::join_paths([dir.path().join("nowhere"), dir.path().to_path_buf()]).unwrap();
430 assert_eq!(resolve_bin(None, Some(path)), None);
431 assert_eq!(resolve_bin(Some(OsString::from("./sgian")), None), None);
432 assert_eq!(trusted_bin(Some(bin.clone().into()), dir.path()), None);
433 let workspace = tempfile::tempdir().unwrap();
434 assert_eq!(
435 trusted_bin(Some(bin.clone().into()), workspace.path()),
436 Some(bin.canonicalize().unwrap())
437 );
438 assert_eq!(
439 resolve_bin(None, Some(OsString::from(dir.path().join("nowhere")))),
440 None
441 );
442 assert_eq!(resolve_bin(None, None), None);
443 }
444
445 #[test]
446 fn holder_names_the_run() {
447 assert_eq!(holder_for("abc"), "kranz:abc");
448 }
449}