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, an explicitly opted-in, uncontained worker 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` must name an absolute helper outside the repository.
15//!   Unset, empty and relative values disable the lane; PATH is never searched.
16//! - Enforced sandbox sessions never receive this host-control capability.
17//! - No binary, no daemon serving the repository root, a refused request, a
18//!   malformed reply or a call that outlasts [`DEADLINE`] all degrade to a
19//!   session without the variable, logged at `debug` (absent daemon is the
20//!   common case) or `warn` (the daemon answered but the reply was unusable).
21//!
22//! Only the credential id and holder are logged; the token crosses into the
23//! session env and nowhere else.
24
25use std::collections::HashMap;
26use std::ffi::OsString;
27use std::path::{Path, PathBuf};
28use std::process::Command;
29use std::time::Duration;
30
31/// Environment variable the worker reads (Sgian's own client convention).
32pub const TOKEN_ENV: &str = "SGIAN_CLIENT_TOKEN";
33/// Operator override for the `sgian` binary; empty disables the lane.
34pub const BIN_ENV: &str = "KRANZ_SGIAN_BIN";
35/// Upper bound on one `sgian ctl` call. The daemon answers on a local socket
36/// in milliseconds; anything longer is a wedged client and the run must not
37/// wait on it.
38pub const DEADLINE: Duration = Duration::from_secs(5);
39
40/// A credential the engine issued for one run and owes a revocation for.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SgianCredential {
43    /// Daemon-assigned credential id (`identity revoke` takes this).
44    pub id: String,
45    /// `kranz:<run-id>`.
46    pub holder: String,
47    bin: PathBuf,
48    workspace: PathBuf,
49}
50
51/// Revokes an issued worker credential even if its running future is dropped.
52pub(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            // Cancellation is best-effort but must not block an async executor.
68            // The helper itself retains its deadline and process-tree cleanup.
69            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
81/// Cancellation during issuance drops the returned guard on the blocking pool,
82/// so even a credential minted after the caller stops gets a revocation attempt.
83pub(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
107/// The holder name Sgian records for a run.
108pub fn holder_for(run_id: &str) -> String {
109    format!("kranz:{run_id}")
110}
111
112/// Issue a `write` credential for `run_id` against the daemon serving
113/// `workspace`, returning the credential and its one-time token, or `None`
114/// when the lane is disabled or unavailable.
115pub 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
120/// [`issue`] against an explicit binary and deadline (the testable core).
121pub 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    /// Attempt revocation. Failure is logged; daemon state must be reconciled
194    /// by the operator if cleanup could not be confirmed.
195    pub fn revoke(&self) {
196        self.revoke_with(DEADLINE);
197    }
198
199    /// [`revoke`](Self::revoke) with an explicit deadline.
200    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
227/// Only an explicit absolute helper opts in. The legacy PATH argument is
228/// retained for API compatibility and is deliberately ignored.
229pub 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    // Resolve symlinks before checking ownership: a repository-provided helper
238    // must never run with the operator's host authority.
239    (bin.is_file() && !bin.starts_with(workspace)).then_some(bin)
240}
241
242/// Run the operator helper with bounded pipes and process-tree cleanup. The
243/// discovery allowlist keeps HOME for daemon lookup, without forwarding provider
244/// credentials or a worker's client token. Never log the helper's reply bytes.
245fn 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
263/// Apply an issued credential to a session env; a convenience for callers
264/// that already hold the token.
265pub 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    /// A fake `sgian` that appends its argv to `<dir>/calls`, then behaves
276    /// per `body` (a shell snippet run with the args still in `$@`).
277    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        // The fixture value stays under eight characters so the repository's own
310        // secret scanner, which runs with the base branch's allowlist, does not
311        // read it as a credential assignment.
312        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        // Prove the same descendant would act without cancellation.
369        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}