use std::collections::HashMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
pub const TOKEN_ENV: &str = "SGIAN_CLIENT_TOKEN";
pub const BIN_ENV: &str = "KRANZ_SGIAN_BIN";
pub const DEADLINE: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SgianCredential {
pub id: String,
pub holder: String,
bin: PathBuf,
workspace: PathBuf,
}
pub(crate) struct RevocationGuard(Option<SgianCredential>);
impl RevocationGuard {
pub(crate) async fn close(mut self) {
if let Some(credential) = self.0.take() {
if let Err(error) = tokio::task::spawn_blocking(move || credential.revoke()).await {
tracing::warn!(%error, "sgian revocation task failed; operator reconciliation required");
}
}
}
}
impl Drop for RevocationGuard {
fn drop(&mut self) {
if let Some(credential) = self.0.take() {
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
runtime.spawn_blocking(move || credential.revoke());
} else if let Err(error) = std::thread::Builder::new()
.name("kranz-sgian-revoke".into())
.spawn(move || credential.revoke())
{
tracing::warn!(%error, "sgian revocation could not start; operator reconciliation required");
}
}
}
}
pub(crate) async fn issue_worker(
workspace: &Path,
session_cwd: &Path,
run_id: &str,
enforce: crate::types::SandboxEnforce,
) -> Option<(RevocationGuard, String)> {
if enforce != crate::types::SandboxEnforce::Off {
return None;
}
let bin = trusted_bin(std::env::var_os(BIN_ENV), workspace)?;
if bin.starts_with(session_cwd.canonicalize().ok()?) {
return None;
}
let workspace = workspace.to_path_buf();
let run_id = run_id.to_string();
tokio::task::spawn_blocking(move || {
issue_with(&bin, &workspace, &run_id, DEADLINE)
.map(|(credential, token)| (RevocationGuard(Some(credential)), token))
})
.await
.ok()
.flatten()
}
pub fn holder_for(run_id: &str) -> String {
format!("kranz:{run_id}")
}
pub fn issue(workspace: &Path, run_id: &str) -> Option<(SgianCredential, String)> {
let bin = trusted_bin(std::env::var_os(BIN_ENV), workspace)?;
issue_with(&bin, workspace, run_id, DEADLINE)
}
pub fn issue_with(
bin: &Path,
workspace: &Path,
run_id: &str,
deadline: Duration,
) -> Option<(SgianCredential, String)> {
let holder = holder_for(run_id);
let args = [
"ctl",
"--workspace",
&workspace.display().to_string(),
"--json",
"identity",
"issue",
"--holder",
&holder,
"--scope",
"write",
];
let output = match run_ctl(bin, &args, deadline) {
Ok(output) => output,
Err(reason) => {
tracing::debug!(
run_id,
workspace = %workspace.display(),
reason,
"sgian credential not issued; the session runs without one"
);
return None;
}
};
let record: serde_json::Value = match serde_json::from_slice(&output) {
Ok(record) => record,
Err(error) => {
tracing::warn!(
run_id,
error = %error,
"sgian identity issue replied with something other than a credential record"
);
return None;
}
};
let id = record.get("id").and_then(serde_json::Value::as_str);
let issued = record.get("token").and_then(serde_json::Value::as_str);
let (Some(id), Some(token)) = (id, issued) else {
tracing::warn!(
run_id,
"sgian identity issue record lacks an id or token; ignoring it"
);
return None;
};
if id.is_empty() || token.is_empty() {
return None;
}
tracing::info!(
run_id,
id,
holder = %holder,
"sgian credential issued for the run (revoked when the run ends)"
);
Some((
SgianCredential {
id: id.to_string(),
holder,
bin: bin.to_path_buf(),
workspace: workspace.to_path_buf(),
},
token.to_string(),
))
}
impl SgianCredential {
pub fn revoke(&self) {
self.revoke_with(DEADLINE);
}
pub fn revoke_with(&self, deadline: Duration) {
let args = [
"ctl",
"--workspace",
&self.workspace.display().to_string(),
"--json",
"identity",
"revoke",
&self.id,
];
match run_ctl(&self.bin, &args, deadline) {
Ok(_) => tracing::info!(
id = %self.id,
holder = %self.holder,
"sgian credential revoked"
),
Err(reason) => tracing::warn!(
id = %self.id,
holder = %self.holder,
reason,
"sgian credential could not be revoked; revoke it by hand with \
`sgian ctl identity revoke` if the daemon is still running"
),
}
}
}
pub fn resolve_bin(override_var: Option<OsString>, _path_var: Option<OsString>) -> Option<PathBuf> {
let path = PathBuf::from(override_var?);
path.is_absolute().then_some(path)
}
fn trusted_bin(override_var: Option<OsString>, workspace: &Path) -> Option<PathBuf> {
let bin = resolve_bin(override_var, None)?.canonicalize().ok()?;
let workspace = workspace.canonicalize().ok()?;
(bin.is_file() && !bin.starts_with(workspace)).then_some(bin)
}
fn run_ctl(bin: &Path, args: &[&str], deadline: Duration) -> Result<Vec<u8>, String> {
let mut command = Command::new(bin);
command
.args(args)
.env_clear()
.envs(crate::agent_env::probe_child_env(&[]));
let output = crate::git_ops::process::output(
command,
crate::git_ops::process::Limits::for_control(deadline),
)
.map_err(|error| format!("{} control call failed: {error}", bin.display()))?;
if output.status.success() {
Ok(output.stdout)
} else {
Err(format!("{} exited with {}", bin.display(), output.status))
}
}
pub fn seed_env(env: &mut HashMap<String, String>, token: String) {
env.insert(TOKEN_ENV.to_string(), token);
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
use std::time::Instant;
fn fake_sgian(dir: &Path, body: &str) -> PathBuf {
let bin = dir.join("sgian");
let calls = dir.join("calls");
std::fs::write(
&bin,
format!(
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n{body}\n",
calls.display()
),
)
.unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
}
fn calls(dir: &Path) -> Vec<String> {
std::fs::read_to_string(dir.join("calls"))
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect()
}
#[test]
fn issue_parses_the_record_and_revoke_uses_its_id() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().join("repo");
std::fs::create_dir_all(&ws).unwrap();
let bin = fake_sgian(
dir.path(),
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"#,
);
let (cred, token) = issue_with(&bin, &ws, "run-1", DEADLINE).expect("credential issued");
assert_eq!(cred.id, "cred-7");
assert_eq!(cred.holder, "kranz:run-1");
assert_eq!(token, "sgc_t");
cred.revoke();
let calls = calls(dir.path());
assert_eq!(
calls,
vec![
format!(
"ctl --workspace {} --json identity issue --holder kranz:run-1 --scope write",
ws.display()
),
format!(
"ctl --workspace {} --json identity revoke cred-7",
ws.display()
),
]
);
}
#[test]
fn issue_is_none_when_ctl_fails_or_answers_nonsense() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().to_path_buf();
let failing = fake_sgian(
dir.path(),
"echo 'no daemon serves this workspace' >&2; exit 1",
);
assert!(issue_with(&failing, &ws, "run-2", DEADLINE).is_none());
let garbage = fake_sgian(dir.path(), "echo 'not json'");
assert!(issue_with(&garbage, &ws, "run-2", DEADLINE).is_none());
let partial = fake_sgian(dir.path(), r#"printf '{"id":"x"}\n'"#);
assert!(issue_with(&partial, &ws, "run-2", DEADLINE).is_none());
}
#[test]
fn issue_gives_up_on_a_wedged_client() {
let dir = tempfile::tempdir().unwrap();
let bin = fake_sgian(dir.path(), "sleep 5");
let started = Instant::now();
assert!(issue_with(&bin, dir.path(), "run-3", Duration::from_millis(200)).is_none());
assert!(
started.elapsed() < Duration::from_secs(3),
"the deadline must cut the wait short"
);
}
#[test]
fn sgian_control_deadline_covers_pipes_after_leader_exit() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("descendant-ran");
let bin = fake_sgian(
dir.path(),
&format!("(sleep 1; touch '{}') & exit 0", marker.display()),
);
assert!(run_ctl(&bin, &[], Duration::from_secs(3)).is_ok());
assert!(marker.exists());
std::fs::remove_file(&marker).unwrap();
let started = Instant::now();
assert!(run_ctl(&bin, &[], Duration::from_millis(100)).is_err());
assert!(started.elapsed() < Duration::from_secs(3));
std::thread::sleep(Duration::from_millis(1200));
assert!(!marker.exists(), "timed-out descendants must not continue");
}
#[test]
fn sgian_control_refuses_oversize_output_and_does_not_log_reply_bytes() {
let dir = tempfile::tempdir().unwrap();
for stream in ["", " >&2"] {
let bin = fake_sgian(dir.path(), &format!("head -c 65537 /dev/zero{stream}"));
assert!(run_ctl(&bin, &[], DEADLINE).is_err());
}
let bin = fake_sgian(dir.path(), "echo 'private-reply' >&2; exit 1");
let error = run_ctl(&bin, &[], DEADLINE).unwrap_err();
assert!(!error.contains("private-reply"));
}
#[test]
fn sgian_control_keeps_discovery_environment_without_ambient_secrets() {
let name =
"sgian::tests::sgian_control_keeps_discovery_environment_without_ambient_secrets";
if crate::agent_env::isolated_global_home_test(name) {
return;
}
std::env::set_var("KRANZ_SGIAN_PRIVATE_FIXTURE", "private-value");
std::env::set_var(TOKEN_ENV, "private-value");
let dir = tempfile::tempdir().unwrap();
let bin = fake_sgian(dir.path(), "env");
let output = run_ctl(&bin, &[], DEADLINE).unwrap();
let env = String::from_utf8(output).unwrap();
assert!(env.lines().any(|line| line.starts_with("HOME=")));
assert!(env.lines().any(|line| line.starts_with("PATH=")));
assert!(!env.contains("KRANZ_SGIAN_PRIVATE_FIXTURE="));
assert!(!env.contains("SGIAN_CLIENT_TOKEN="));
}
#[test]
fn issue_is_none_when_the_binary_is_missing() {
let dir = tempfile::tempdir().unwrap();
assert!(issue_with(&dir.path().join("absent"), dir.path(), "run-4", DEADLINE).is_none());
}
#[test]
fn resolve_bin_requires_explicit_absolute_opt_in() {
let dir = tempfile::tempdir().unwrap();
let bin = fake_sgian(dir.path(), "true");
assert_eq!(
resolve_bin(Some(OsString::from("/opt/sgian")), None),
Some(PathBuf::from("/opt/sgian"))
);
assert_eq!(
resolve_bin(Some(OsString::new()), Some(dir.path().into())),
None
);
let path =
std::env::join_paths([dir.path().join("nowhere"), dir.path().to_path_buf()]).unwrap();
assert_eq!(resolve_bin(None, Some(path)), None);
assert_eq!(resolve_bin(Some(OsString::from("./sgian")), None), None);
assert_eq!(trusted_bin(Some(bin.clone().into()), dir.path()), None);
let workspace = tempfile::tempdir().unwrap();
assert_eq!(
trusted_bin(Some(bin.clone().into()), workspace.path()),
Some(bin.canonicalize().unwrap())
);
assert_eq!(
resolve_bin(None, Some(OsString::from(dir.path().join("nowhere")))),
None
);
assert_eq!(resolve_bin(None, None), None);
}
#[test]
fn holder_names_the_run() {
assert_eq!(holder_for("abc"), "kranz:abc");
}
}