Skip to main content

kranz_engine/
sgian.rs

1//! Per-run Sgian client credential (optional coordination lane).
2//!
3//! When the operator runs a mission inside a repository that a Sgian daemon
4//! also serves, every worker session the engine spawns identifies itself to
5//! that daemon as its own principal: the engine asks the daemon for a
6//! credential held by `kranz:<run-id>` with the `write` scope, hands the
7//! token to the worker through `SGIAN_CLIENT_TOKEN`, and revokes the
8//! credential when the run ends. Sgian then attributes every pane the worker
9//! drives, every lease it takes and every ledger record it produces to the
10//! run rather than to the operator. Revocation is best-effort, including on
11//! future cancellation; engine death still needs operator reconciliation.
12//!
13//! The lane is best-effort and never a reason to fail a spawn:
14//! - `KRANZ_SGIAN_BIN` set to a path uses that `sgian` binary; set but empty
15//!   disables the lane; unset searches `PATH` for `sgian`.
16//! - No binary, no daemon serving the repository root, a refused request, a
17//!   malformed reply or a call that outlasts [`DEADLINE`] all degrade to a
18//!   session without the variable, logged at `debug` (absent daemon is the
19//!   common case) or `warn` (the daemon answered but the reply was unusable).
20//!
21//! Only the credential id and holder are logged; the token crosses into the
22//! session env and nowhere else.
23
24use std::collections::HashMap;
25use std::ffi::OsString;
26use std::path::{Path, PathBuf};
27use std::process::Command;
28use std::time::Duration;
29
30/// Environment variable the worker reads (Sgian's own client convention).
31pub const TOKEN_ENV: &str = "SGIAN_CLIENT_TOKEN";
32/// Operator override for the `sgian` binary; empty disables the lane.
33pub const BIN_ENV: &str = "KRANZ_SGIAN_BIN";
34/// Upper bound on one `sgian ctl` call. The daemon answers on a local socket
35/// in milliseconds; anything longer is a wedged client and the run must not
36/// wait on it.
37pub const DEADLINE: Duration = Duration::from_secs(5);
38
39/// A credential the engine issued for one run and owes a revocation for.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SgianCredential {
42    /// Daemon-assigned credential id (`identity revoke` takes this).
43    pub id: String,
44    /// `kranz:<run-id>`.
45    pub holder: String,
46    bin: PathBuf,
47    workspace: PathBuf,
48}
49
50/// Revokes an issued worker credential even if its running future is dropped.
51pub(crate) struct RevocationGuard(pub SgianCredential);
52
53impl Drop for RevocationGuard {
54    fn drop(&mut self) {
55        self.0.revoke();
56    }
57}
58
59/// The holder name Sgian records for a run.
60pub fn holder_for(run_id: &str) -> String {
61    format!("kranz:{run_id}")
62}
63
64/// Issue a `write` credential for `run_id` against the daemon serving
65/// `workspace`, returning the credential and its one-time token, or `None`
66/// when the lane is disabled or unavailable.
67pub fn issue(workspace: &Path, run_id: &str) -> Option<(SgianCredential, String)> {
68    let bin = resolve_bin(std::env::var_os(BIN_ENV), std::env::var_os("PATH"))?;
69    issue_with(&bin, workspace, run_id, DEADLINE)
70}
71
72/// [`issue`] against an explicit binary and deadline (the testable core).
73pub fn issue_with(
74    bin: &Path,
75    workspace: &Path,
76    run_id: &str,
77    deadline: Duration,
78) -> Option<(SgianCredential, String)> {
79    let holder = holder_for(run_id);
80    let args = [
81        "ctl",
82        "--workspace",
83        &workspace.display().to_string(),
84        "--json",
85        "identity",
86        "issue",
87        "--holder",
88        &holder,
89        "--scope",
90        "write",
91    ];
92    let output = match run_ctl(bin, &args, deadline) {
93        Ok(output) => output,
94        Err(reason) => {
95            tracing::debug!(
96                run_id,
97                workspace = %workspace.display(),
98                reason,
99                "sgian credential not issued; the session runs without one"
100            );
101            return None;
102        }
103    };
104    let record: serde_json::Value = match serde_json::from_slice(&output) {
105        Ok(record) => record,
106        Err(error) => {
107            tracing::warn!(
108                run_id,
109                error = %error,
110                "sgian identity issue replied with something other than a credential record"
111            );
112            return None;
113        }
114    };
115    let id = record.get("id").and_then(serde_json::Value::as_str);
116    let issued = record.get("token").and_then(serde_json::Value::as_str);
117    let (Some(id), Some(token)) = (id, issued) else {
118        tracing::warn!(
119            run_id,
120            "sgian identity issue record lacks an id or token; ignoring it"
121        );
122        return None;
123    };
124    if id.is_empty() || token.is_empty() {
125        return None;
126    }
127    tracing::info!(
128        run_id,
129        id,
130        holder = %holder,
131        "sgian credential issued for the run (revoked when the run ends)"
132    );
133    Some((
134        SgianCredential {
135            id: id.to_string(),
136            holder,
137            bin: bin.to_path_buf(),
138            workspace: workspace.to_path_buf(),
139        },
140        token.to_string(),
141    ))
142}
143
144impl SgianCredential {
145    /// Attempt revocation. Failure is logged; daemon state must be reconciled
146    /// by the operator if cleanup could not be confirmed.
147    pub fn revoke(&self) {
148        self.revoke_with(DEADLINE);
149    }
150
151    /// [`revoke`](Self::revoke) with an explicit deadline.
152    pub fn revoke_with(&self, deadline: Duration) {
153        let args = [
154            "ctl",
155            "--workspace",
156            &self.workspace.display().to_string(),
157            "--json",
158            "identity",
159            "revoke",
160            &self.id,
161        ];
162        match run_ctl(&self.bin, &args, deadline) {
163            Ok(_) => tracing::info!(
164                id = %self.id,
165                holder = %self.holder,
166                "sgian credential revoked"
167            ),
168            Err(reason) => tracing::warn!(
169                id = %self.id,
170                holder = %self.holder,
171                reason,
172                "sgian credential could not be revoked; revoke it by hand with \
173                 `sgian ctl identity revoke` if the daemon is still running"
174            ),
175        }
176    }
177}
178
179/// Pick the `sgian` binary: the override wins, an empty override disables,
180/// otherwise the first `sgian` on `PATH`.
181pub fn resolve_bin(override_var: Option<OsString>, path_var: Option<OsString>) -> Option<PathBuf> {
182    match override_var {
183        Some(value) if value.is_empty() => None,
184        Some(value) => Some(PathBuf::from(value)),
185        None => std::env::split_paths(&path_var?).find_map(|dir| {
186            let candidate = dir.join(BIN_NAME);
187            candidate.is_file().then_some(candidate)
188        }),
189    }
190}
191
192#[cfg(windows)]
193const BIN_NAME: &str = "sgian.exe";
194#[cfg(not(windows))]
195const BIN_NAME: &str = "sgian";
196
197/// Run the operator helper with bounded pipes and process-tree cleanup. The
198/// discovery allowlist keeps HOME for daemon lookup, without forwarding provider
199/// credentials or a worker's client token. Never log the helper's reply bytes.
200fn run_ctl(bin: &Path, args: &[&str], deadline: Duration) -> Result<Vec<u8>, String> {
201    let mut command = Command::new(bin);
202    command
203        .args(args)
204        .env_clear()
205        .envs(crate::agent_env::probe_child_env(&[]));
206    let output = crate::git_ops::process::output(
207        command,
208        crate::git_ops::process::Limits::for_control(deadline),
209    )
210    .map_err(|error| format!("{} control call failed: {error}", bin.display()))?;
211    if output.status.success() {
212        Ok(output.stdout)
213    } else {
214        Err(format!("{} exited with {}", bin.display(), output.status))
215    }
216}
217
218/// Apply an issued credential to a session env; a convenience for callers
219/// that already hold the token.
220pub fn seed_env(env: &mut HashMap<String, String>, token: String) {
221    env.insert(TOKEN_ENV.to_string(), token);
222}
223
224#[cfg(all(test, unix))]
225mod tests {
226    use super::*;
227    use std::os::unix::fs::PermissionsExt;
228    use std::time::Instant;
229
230    /// A fake `sgian` that appends its argv to `<dir>/calls`, then behaves
231    /// per `body` (a shell snippet run with the args still in `$@`).
232    fn fake_sgian(dir: &Path, body: &str) -> PathBuf {
233        let bin = dir.join("sgian");
234        let calls = dir.join("calls");
235        std::fs::write(
236            &bin,
237            format!(
238                "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n{body}\n",
239                calls.display()
240            ),
241        )
242        .unwrap();
243        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
244        bin
245    }
246
247    fn calls(dir: &Path) -> Vec<String> {
248        std::fs::read_to_string(dir.join("calls"))
249            .unwrap_or_default()
250            .lines()
251            .map(str::to_string)
252            .collect()
253    }
254
255    #[test]
256    fn issue_parses_the_record_and_revoke_uses_its_id() {
257        let dir = tempfile::tempdir().unwrap();
258        let ws = dir.path().join("repo");
259        std::fs::create_dir_all(&ws).unwrap();
260        let bin = fake_sgian(
261            dir.path(),
262            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"#,
263        );
264        // The fixture value stays under eight characters so the repository's own
265        // secret scanner, which runs with the base branch's allowlist, does not
266        // read it as a credential assignment.
267        let (cred, token) = issue_with(&bin, &ws, "run-1", DEADLINE).expect("credential issued");
268        assert_eq!(cred.id, "cred-7");
269        assert_eq!(cred.holder, "kranz:run-1");
270        assert_eq!(token, "sgc_t");
271        cred.revoke();
272        let calls = calls(dir.path());
273        assert_eq!(
274            calls,
275            vec![
276                format!(
277                    "ctl --workspace {} --json identity issue --holder kranz:run-1 --scope write",
278                    ws.display()
279                ),
280                format!(
281                    "ctl --workspace {} --json identity revoke cred-7",
282                    ws.display()
283                ),
284            ]
285        );
286    }
287
288    #[test]
289    fn issue_is_none_when_ctl_fails_or_answers_nonsense() {
290        let dir = tempfile::tempdir().unwrap();
291        let ws = dir.path().to_path_buf();
292        let failing = fake_sgian(
293            dir.path(),
294            "echo 'no daemon serves this workspace' >&2; exit 1",
295        );
296        assert!(issue_with(&failing, &ws, "run-2", DEADLINE).is_none());
297        let garbage = fake_sgian(dir.path(), "echo 'not json'");
298        assert!(issue_with(&garbage, &ws, "run-2", DEADLINE).is_none());
299        let partial = fake_sgian(dir.path(), r#"printf '{"id":"x"}\n'"#);
300        assert!(issue_with(&partial, &ws, "run-2", DEADLINE).is_none());
301    }
302
303    #[test]
304    fn issue_gives_up_on_a_wedged_client() {
305        let dir = tempfile::tempdir().unwrap();
306        let bin = fake_sgian(dir.path(), "sleep 5");
307        let started = Instant::now();
308        assert!(issue_with(&bin, dir.path(), "run-3", Duration::from_millis(200)).is_none());
309        assert!(
310            started.elapsed() < Duration::from_secs(3),
311            "the deadline must cut the wait short"
312        );
313    }
314
315    #[test]
316    fn sgian_control_deadline_covers_pipes_after_leader_exit() {
317        let dir = tempfile::tempdir().unwrap();
318        let marker = dir.path().join("descendant-ran");
319        let bin = fake_sgian(
320            dir.path(),
321            &format!("(sleep 1; touch '{}') & exit 0", marker.display()),
322        );
323        // Prove the same descendant would act without cancellation.
324        assert!(run_ctl(&bin, &[], Duration::from_secs(3)).is_ok());
325        assert!(marker.exists());
326        std::fs::remove_file(&marker).unwrap();
327        let started = Instant::now();
328        assert!(run_ctl(&bin, &[], Duration::from_millis(100)).is_err());
329        assert!(started.elapsed() < Duration::from_secs(3));
330        std::thread::sleep(Duration::from_millis(1200));
331        assert!(!marker.exists(), "timed-out descendants must not continue");
332    }
333
334    #[test]
335    fn sgian_control_refuses_oversize_output_and_does_not_log_reply_bytes() {
336        let dir = tempfile::tempdir().unwrap();
337        for stream in ["", " >&2"] {
338            let bin = fake_sgian(dir.path(), &format!("head -c 65537 /dev/zero{stream}"));
339            assert!(run_ctl(&bin, &[], DEADLINE).is_err());
340        }
341        let bin = fake_sgian(dir.path(), "echo 'private-reply' >&2; exit 1");
342        let error = run_ctl(&bin, &[], DEADLINE).unwrap_err();
343        assert!(!error.contains("private-reply"));
344    }
345
346    #[test]
347    fn sgian_control_keeps_discovery_environment_without_ambient_secrets() {
348        let name =
349            "sgian::tests::sgian_control_keeps_discovery_environment_without_ambient_secrets";
350        if crate::agent_env::isolated_global_home_test(name) {
351            return;
352        }
353        std::env::set_var("KRANZ_SGIAN_PRIVATE_FIXTURE", "private-value");
354        std::env::set_var(TOKEN_ENV, "private-value");
355        let dir = tempfile::tempdir().unwrap();
356        let bin = fake_sgian(dir.path(), "env");
357        let output = run_ctl(&bin, &[], DEADLINE).unwrap();
358        let env = String::from_utf8(output).unwrap();
359        assert!(env.lines().any(|line| line.starts_with("HOME=")));
360        assert!(env.lines().any(|line| line.starts_with("PATH=")));
361        assert!(!env.contains("KRANZ_SGIAN_PRIVATE_FIXTURE="));
362        assert!(!env.contains("SGIAN_CLIENT_TOKEN="));
363    }
364
365    #[test]
366    fn issue_is_none_when_the_binary_is_missing() {
367        let dir = tempfile::tempdir().unwrap();
368        assert!(issue_with(&dir.path().join("absent"), dir.path(), "run-4", DEADLINE).is_none());
369    }
370
371    #[test]
372    fn resolve_bin_honours_the_override_and_searches_path() {
373        let dir = tempfile::tempdir().unwrap();
374        let bin = fake_sgian(dir.path(), "true");
375        assert_eq!(
376            resolve_bin(Some(OsString::from("/opt/sgian")), None),
377            Some(PathBuf::from("/opt/sgian"))
378        );
379        assert_eq!(
380            resolve_bin(Some(OsString::new()), Some(dir.path().into())),
381            None
382        );
383        let path =
384            std::env::join_paths([dir.path().join("nowhere"), dir.path().to_path_buf()]).unwrap();
385        assert_eq!(resolve_bin(None, Some(path)), Some(bin));
386        assert_eq!(
387            resolve_bin(None, Some(OsString::from(dir.path().join("nowhere")))),
388            None
389        );
390        assert_eq!(resolve_bin(None, None), None);
391    }
392
393    #[test]
394    fn holder_names_the_run() {
395        assert_eq!(holder_for("abc"), "kranz:abc");
396    }
397}