Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

A self-hosted runner leaves the computer it runs on as little of a job's secrets as it can. A workflow job's token, which sits where the computer can see it (a Windows job's script in the folder Windows Sandbox reads, a container's environment), is spent on fetching the job: the harness asks for it (rotate, set by the self-hosted runner through G1T_ROTATE_JOB_TOKEN), and job_spec answers with a new token beside the spec, swapped in last and only while the old one is still the job's, so a copy left behind, or a second fetch racing the first, is refused and can't fetch the spec, its secrets or a G1T_TOKEN again. The harness reports, logs, uploads and caches with the new one, masked in the log, and the toolkit's runtime token is signed for it. Harnesses that don't ask, g1t's own sandboxes among them (which keep their token to report a sandbox that stopped), keep theirs, and a harness that asks a g1t that doesn't rotate gets no new token and carries on as before. A Windows job's folder is deleted when the job ends, as before, and now also when the runner next starts after being stopped mid-job (a crash, or taskkill from sandbox down).

The runner's folders are their owner's alone. On macOS and Linux they are made 0700, as their files were already 0600. On Windows a folder outside the user's profile (--dir, G1T_RUNNER_DIR or --state-dir at C:\runner) inherited read access for every user, credential, job scripts and VM disk included: the runner now reads the folder's DACL as SDDL, and when Everyone, Users, Authenticated Users or the like may read it, leaves it to the account, SYSTEM and Administrators with nothing inherited (icacls, by SID), and refuses to register, run or start the sandbox with a clear fix if it stays open. private.rs holds this and write_private, which the launcher and config::save now share. An update's carried registration is removed as soon as no sandbox needs it, and whenever the old system is still there; the next up imports one an update left half-way whether or not it makes the VM. g1t-runner sandbox status and up warn of a runner registered for host mode under the same account (~/.g1t-runner, G1T_RUNNER_DIR, or the desktop app's runner/host folder), whose jobs could look inside the sandbox, and of a state folder others can read: a Warning: line, and warnings in status --json (added; the schema is unchanged). The WSL distribution's default user is a locked account with no shell or home, made in the image, so Explorer at \\wsl.localhost\g1t-sandbox or a bare wsl -d is no longer root; every call the launcher makes names root itself. On macOS and Linux VMs the launcher takes the registration token out of the seed disk once the runner has registered, as the WSL guest and Sysbox already did. The self-hosted runners guide has a section, What your computer can see: the sandbox keeps jobs from the computer, a job's log and secrets go from inside it straight to g1t, anything running as the same account (or an administrator, root in another WSL distribution, the docker group under Sysbox) can look inside a running sandbox, so no host-mode runner or untrusted software under that account; the job's token on the computer is spent after first use; the folders are owner-only. The desktop guide points to it under How jobs run, and the status table lists warnings.

syntaqxcommitted Parent9e4606fBrowse files
17 files+823−590/17 viewed
+7−2
769769 _ => (rest.to_owned(), "job_report"),
770770 };
771771 let body = json_body(&mut request).await;
772+ // `rotate`: a harness that takes a new token with the spec and
773+ // lets go of the one it asked with (plan.rs `job_spec`).
774+ let rotate = method == "job_spec" && body["rotate"].as_bool() == Some(true);
772775 let answered: Outcome<Value> = g1t_kit::call(
773776 &services.actions,
774777 method,
775− &json!({ "job": job, "token": body["token"], "report": body["report"] }),
778+ &json!({ "job": job, "token": body["token"], "report": body["report"], "rotate": rotate }),
776779 )
777780 .await?;
778781 return match answered {
942945 #[test]
943946 fn a_job_spec_gets_the_toolkits_variables() {
944947 // As the actions service sends it, converted as the API does.
945− let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } });
948+ let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true }, "nextToken": "n" });
946949 let mut spec = g1t_kit::wire::snake_case_keeping(sent.clone(), super::JOB_SPEC_AS_GIVEN);
947950 super::with_runtime(&mut spec, "https://api.g1t.sh", true);
948951 assert!(spec.get("runtime").is_none(), "the runner never sees it");
952+ // The token that replaces the one the harness asked with.
953+ assert_eq!(spec["next_token"], "n");
949954 let vars = &spec["variables"];
950955 assert_eq!(vars["GITHUB_SHA"], "abc");
951956 assert_eq!(vars["ACTIONS_RUNTIME_TOKEN"], "h.p.s");
+13−0
213213 The sandbox also needs virtualization turned on in your computer's
214214 firmware (Intel VT-x or AMD-V), which most computers have on.
215215
216+The sandbox keeps jobs from your computer, not your computer from the
217+sandbox: anything running as your account can look inside it while a job
218+runs. So don't choose **Directly on this computer** under the same
219+account you use for the sandbox, and don't run software you don't trust
220+there. A runner registered for running jobs directly stays registered when
221+you choose another way, so remove it once you no longer use it:
222+`g1t-runner remove --dir <data folder>/host`, with the data folder below.
223+`g1t-runner sandbox status` warns while one is there. A job's secrets go from inside the sandbox
224+straight to g1t, the job's token left on your computer is spent once the
225+job has started, and the runner's folders are yours alone. [What your
226+computer can see](/guides/self-hosted-runners/#what-your-computer-can-see)
227+has the details.
228+
216229 For the sandbox, the app runs the same commands you would, with its own
217230 copy of `g1t-runner` and its state in the app's local data folder, which
218231 never roams to other computers with your profile
+60−2
313313 run on a Mac or a Linux machine without Sysbox. Windows is where the
314314 sandbox has been checked end to end.
315315
316+### What your computer can see
317+
318+The sandbox keeps jobs from reaching your computer. It does not keep your
319+computer from looking at the sandbox, and so at what a job has while it
320+runs:
321+
322+- **A job's log and secrets go from inside the sandbox straight to g1t.**
323+ The runner inside fetches the job, its secrets and its `G1T_TOKEN`
324+ itself, and sends its log back the same way, masked. None of it passes
325+ through a log or file of your computer's.
326+- **Anything running as your account can look inside a running
327+ sandbox**, as you can: open the WSL distribution with `wsl -u root`,
328+ run a command in a Windows Sandbox, or read the VM's disk. So can an
329+ administrator, root in another WSL distribution (all of WSL 2 shares one
330+ VM), and on Linux with Sysbox anyone in the `docker` group. Don't run a
331+ [host-mode](#host-mode-is-deprecated) runner, or software you don't
332+ trust, under the account the sandbox runs as: what its jobs run could
333+ read the sandbox's runner credentials and the secrets of the job
334+ running in it. `g1t-runner sandbox status` warns when it finds a
335+ host-mode runner registered for the same account (in `~/.g1t-runner`,
336+ `G1T_RUNNER_DIR`, or the desktop app's folder for running jobs directly
337+ on the computer). Remove it with `g1t-runner remove --dir <its folder>`,
338+ or register it under another account.
339+- **The job's token on your computer is spent after its first use.** A
340+ workflow job reaches its runner with a token of its own, which sits
341+ where your computer can see it: in a Windows job's script, in the job
342+ folder Windows Sandbox reads, and in a container's environment. The
343+ first thing the runner in the sandbox does is fetch the job with it,
344+ and g1t answers with a new token that only the sandbox has; the first
345+ stops working at once, for that and for everything else. A copy left
346+ behind can't fetch the job, its secrets or a `G1T_TOKEN` again. A job's
347+ folder is deleted when the job ends, and a runner that was stopped
348+ mid-job (a crash, or `taskkill`) deletes what it left when it next
349+ starts.
350+- **The registration token goes once the runner has registered.** It is
351+ kept only until the runner inside has registered with it; then the
352+ launcher takes it out of the VM's seed disk on macOS and Linux, and the
353+ sandbox out of its own settings. On Windows, an `update` keeps the
354+ runner's registration on your computer only while the VM is swapped.
355+- **The state folders are yours alone.** The runner's folder
356+ (`~/.g1t-runner`, `--dir` or `G1T_RUNNER_DIR`) and the sandbox's
357+ (`--state-dir`) hold the runners' credentials, a Windows job's script
358+ while it runs, and the VM's disk. On macOS and Linux they are made
359+ `0700` and their files `0600`. On Windows a folder in your profile is
360+ already yours alone; one elsewhere, such as `C:\runner`, would let every
361+ user of the computer read it, so the runner makes it private (you,
362+ SYSTEM and Administrators, and nothing inherited from the folder
363+ above). If it can't, it refuses to start and says how to fix it.
364+- **On Windows, opening the sandbox's distribution without naming a user**
365+ (Explorer at `\\wsl.localhost\g1t-sandbox`, or `wsl -d g1t-sandbox`)
366+ gets a locked account with no shell, which can't read the runner's
367+ registration. The launcher names root itself for what it does there.
368+
316369 ### The status as JSON
317370
318371 `g1t-runner sandbox status --json` prints one JSON object, which [the
341394 "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
342395 "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." }
343396 ],
344− "state_dir": "C:\\Users\\you\\.g1t-runner\\sandbox"
397+ "state_dir": "C:\\Users\\you\\.g1t-runner\\sandbox",
398+ "warnings": []
345399 }
346400 ```
347401
354408 | `error` | A sentence for a person when something is wrong, or null. |
355409 | `capabilities` | Each kind of job the computer can run sandboxed: `linux`, and `windows` or `macos`. `available` says whether it can be had here; when not, `cause` says why and `fix` what to do. Each has its own `state`, `job` and `runner_version`. A native one whose runner didn't start is in `error`, with why in `cause`. |
356410 | `turned_off` | On each capability: someone stopped it (`down --only`, `--no-native`). Its `state` is then `stopped`: stopped by a person, not failing, and nothing in `error`. |
411+| `warnings` | Sentences about what on the computer weakens the sandbox ([what your computer can see](#what-your-computer-can-see)): a host-mode runner registered for the same account, or a state folder other users can read. Empty when nothing does; left out by earlier versions of `g1t-runner`. `status` without `--json` prints each as a line starting `Warning:`. |
357412
358413 Fields are only added; `schema` changes if one changes meaning.
359414
661716 given and remove itself, and nothing else; every other endpoint refuses
662717 it. It is kept only as a hash on g1t's side, rotates every day, and stops
663718 working the moment the runner is removed. It is never given to a job:
664− each job gets only its own short-lived token.
719+ each job gets only its own short-lived token, which the job spends on
720+ fetching itself and replaces with one only the job has. The runner's
721+ folder is its owner's alone, and the runner refuses to start in one
722+ other users can read ([what your computer can see](#what-your-computer-can-see)).
665723 - **Isolation**: run the runner's image under Sysbox. Each job then gets
666724 a container of its own inside the runner's container, removed after,
667725 and neither reaches the machine. A job with `--no-docker` runs as the
+6−0
991991 pub token: String,
992992 #[serde(default)]
993993 pub report: Value,
994+ /// `job_spec` only: the harness takes a new token with the spec, and
995+ /// the one it asked with stops working, so a copy of it left on the
996+ /// runner's computer cannot fetch the job's secrets again. Harnesses
997+ /// that predate it never ask, and keep their token.
998+ #[serde(default)]
999+ pub rotate: bool,
9941000 }
9951001
9961002 /// What the runner needs to start a job's sandbox.
+12−2
3535 use report::{Api, Log};
3636
3737 const TEMP: &str = "/home/runner/_temp";
38+/// Set to `1` by the self-hosted runner: the job's token is spent on its
39+/// spec, and the harness carries on with the one that replaces it
40+/// (`report::Api::spec`). A `G1T_` variable, so no step sees it.
41+pub(crate) const ROTATE_TOKEN: &str = "G1T_ROTATE_JOB_TOKEN";
3842
3943 /// Who is running steps: the job itself, or a composite action inside it.
4044 #[derive(Clone, Default)]
12091213 }
12101214
12111215 pub(crate) fn main() -> i32 {
1212− let api = match (crate::env("G1T_API"), crate::env("ACTIONS_JOB"), crate::env("ACTIONS_TOKEN")) {
1216+ let mut api = match (crate::env("G1T_API"), crate::env("ACTIONS_JOB"), crate::env("ACTIONS_TOKEN")) {
12131217 (Ok(base), Ok(job), Ok(token)) => Api { base, job, token },
12141218 _ => {
12151219 eprintln!("g1t-runner: G1T_API, ACTIONS_JOB and ACTIONS_TOKEN are needed");
12161220 return 2;
12171221 }
12181222 };
1219− let spec = match api.spec() {
1223+ // A self-hosted runner asks for the token to be spent on the spec
1224+ // (selfhosted/exec.rs): there, the token it was given sits where the
1225+ // computer can see it (a Windows job's script, a container's
1226+ // environment). g1t's own sandboxes keep theirs to report a sandbox
1227+ // that stopped, and do not ask.
1228+ let rotate = std::env::var(ROTATE_TOKEN).is_ok_and(|v| v == "1");
1229+ let spec = match api.spec(rotate) {
12201230 Ok(spec) => spec,
12211231 Err(error) => {
12221232 eprintln!("g1t-runner: could not fetch the job: {error:#}");
+41−5
7979 }
8080
8181 impl Api {
82− pub(crate) fn spec(&self) -> Result<Value> {
83− let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job))
84− .timeout(Duration::from_secs(60))
85− .send_json(json!({ "token": self.token }))?;
86− Ok(response.into_json()?)
82+ /// The job's spec. With `rotate`, g1t spends the token it was asked
83+ /// with and sends a new one beside the spec, which this takes for
84+ /// everything after (`next_token`): a copy of the first left on the
85+ /// runner's computer cannot fetch the job, its secrets or a G1T_TOKEN
86+ /// again. A g1t that predates it sends none, and the token stays.
87+ pub(crate) fn spec(&mut self, rotate: bool) -> Result<Value> {
88+ let mut body = json!({ "token": self.token });
89+ if rotate {
90+ body["rotate"] = json!(true);
91+ }
92+ let response = ureq::post(&format!("{}/actions/jobs/{}/spec", self.base, self.job)).timeout(Duration::from_secs(60)).send_json(body)?;
93+ let mut spec: Value = response.into_json()?;
94+ self.take_next_token(&mut spec);
95+ Ok(spec)
96+ }
97+
98+ /// Takes the token a spec carries in place of the one it was fetched
99+ /// with, and masks it in the log.
100+ fn take_next_token(&mut self, spec: &mut Value) {
101+ let next = spec.as_object_mut().and_then(|s| s.remove("next_token").or_else(|| s.remove("nextToken")));
102+ if let Some(next) = next.as_ref().and_then(Value::as_str).filter(|t| !t.is_empty()) {
103+ self.token = next.to_owned();
104+ if let Some(masks) = spec.get_mut("masks").and_then(Value::as_array_mut) {
105+ masks.push(json!(next));
106+ }
107+ }
87108 }
88109
89110 /// Where to fetch another repository's action from: `{"source": "g1t",
442463 use super::*;
443464
444465 #[test]
466+ fn the_token_a_spec_carries_replaces_the_one_it_was_fetched_with() {
467+ let mut api = Api::offline();
468+ let mut spec = json!({ "job": "job_1", "masks": ["s3cret"], "next_token": "fresh" });
469+ api.take_next_token(&mut spec);
470+ assert_eq!(api.token, "fresh");
471+ assert!(spec.get("next_token").is_none(), "the job's steps never see it");
472+ assert_eq!(spec["masks"], json!(["s3cret", "fresh"]));
473+ // A g1t that does not rotate sends none: the token stays.
474+ let mut spec = json!({ "job": "job_1", "masks": [] });
475+ api.take_next_token(&mut spec);
476+ assert_eq!(api.token, "fresh");
477+ assert_eq!(spec["masks"], json!([]));
478+ }
479+
480+ #[test]
445481 fn every_line_is_stamped_with_the_time_as_github_stamps_it() {
446482 assert_eq!(stamp(UNIX_EPOCH), "1970-01-01T00:00:00.0000000Z");
447483 assert_eq!(stamp(UNIX_EPOCH + Duration::new(1_791_797_067, 123_456_789)), "2026-10-12T09:24:27.1234567Z");
+5−13
9999 serde_json::from_str(&text).with_context(|| format!("{} does not read", file.display()))
100100 }
101101
102−/// Writes the configuration so only its owner can read it, through a
103−/// temporary file, so a crash never leaves half a credential.
102+/// Writes the configuration so only its owner can read it, in a folder
103+/// only its owner can read (private.rs), through a temporary file, so a
104+/// crash never leaves half a credential.
104105 pub fn save(folder: &Path, config: &Config) -> Result<()> {
105− std::fs::create_dir_all(folder).with_context(|| format!("could not make {}", folder.display()))?;
106− let file = path(folder);
107− let temp = folder.join("config.json.new");
108− std::fs::write(&temp, serde_json::to_string_pretty(config)?)?;
109− #[cfg(unix)]
110− {
111− use std::os::unix::fs::PermissionsExt;
112− std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?;
113− }
114− std::fs::rename(&temp, &file).with_context(|| format!("could not write {}", file.display()))?;
115− Ok(())
106+ super::private::folder(folder, "--dir")?;
107+ super::private::write_private(&path(folder), serde_json::to_string_pretty(config)?.as_bytes())
116108 }
117109
118110 pub fn forget(folder: &Path) {
+8−0
203203 env.insert("G1T_API".into(), config.api.clone());
204204 env.insert("ACTIONS_JOB".into(), work.id.clone());
205205 env.insert("ACTIONS_TOKEN".into(), token.clone());
206+ // That token is where this computer can see it (a Windows job's
207+ // script in the job's folder, a container's environment): the
208+ // harness spends it on fetching the job and works on with a new
209+ // one only it has.
210+ env.insert(crate::actions::ROTATE_TOKEN.into(), "1".into());
206211 }
207212 "agent" => {
208213 for (name, value) in work.env.clone().unwrap_or_default() {
622627 assert_eq!(env["ACTIONS_TOKEN"], "jobtoken");
623628 assert_eq!(env["G1T_API"], "https://api.g1t.sh");
624629 assert!(env.values().all(|value| !value.contains("g1tr_")));
630+ // That token is spent on fetching the job.
631+ assert_eq!(env[crate::actions::ROTATE_TOKEN], "1");
632+ assert!(!harness_env(&config(), &assignment("agent")).unwrap().contains_key(crate::actions::ROTATE_TOKEN));
625633 }
626634
627635 #[test]
+7−1
852852 #[test]
853853 fn the_image_turns_off_every_way_to_windows() {
854854 let conf = WSL_CONF.replace(' ', "");
855− for setting in ["[automount]\nenabled=false", "mountFsTab=false", "[interop]\nenabled=false", "appendWindowsPath=false", "default=root"] {
855+ for setting in ["[automount]\nenabled=false", "mountFsTab=false", "[interop]\nenabled=false", "appendWindowsPath=false", "[user]\ndefault=sandbox"] {
856856 assert!(conf.contains(setting), "{setting}");
857857 }
858+ // What opens the distribution without naming a user is not root;
859+ // the launcher names root on every call (wsl.rs).
860+ assert!(!conf.contains("default=root"));
861+ let dockerfile = include_str!("../../../../../deploy/sandbox/Dockerfile");
862+ assert!(dockerfile.contains("useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin sandbox"));
863+ assert!(dockerfile.contains("passwd --lock sandbox"));
858864 }
859865
860866 #[test]
+127−28
6868 //! "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
6969 //! "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, …" }
7070 //! ],
71−//! "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox"
71+//! "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox",
72+//! "warnings": []
7273 //! }
7374 //! ```
7475 //!
7778 //! or `error`; `error` is a sentence for a person, set whenever something
7879 //! is wrong, and `cause`/`fix` say why a capability is unavailable, or for
7980 //! a native one in `error` why it didn't start, and what to do.
81+//! `warnings` holds sentences about what on the computer weakens the
82+//! sandbox: a runner of the same account registered for host mode, whose
83+//! jobs could look inside it, or a state folder others can read. It is
84+//! empty when nothing does, and left out by launchers before it.
8085 //! `turned_off` is true for a capability someone stopped (`down --only`,
8186 //! `--no-native`), whose state is then `stopped`: stopped by a person, not
8287 //! failing. `job` is what the runner is running now, or null. Fields
102107 use serde::{Deserialize, Serialize};
103108
104109 use super::native::{Availability, MacVm, WsbFound};
110+use super::private::{self, write_private};
105111 use super::{JobStatus, Options, RunnerStatus, VERSION, config, log};
106112 use guest::{GuestStatus, Settings};
107113
248254 pub resources: Resources,
249255 pub capabilities: Vec<Capability>,
250256 pub state_dir: String,
257+ /// Sentences about what on the computer weakens the sandbox (a
258+ /// host-mode runner of the same account, a state folder others can
259+ /// read); empty when nothing does. Older launchers leave it out.
260+ #[serde(default)]
261+ pub warnings: Vec<String>,
251262 }
252263
253264 /// One kind of job this computer can run sandboxed.
267278 pub turned_off: bool,
268279 }
269280
270−pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
271− let temp = path.with_extension("new");
272− std::fs::write(&temp, bytes).with_context(|| format!("could not write {}", temp.display()))?;
273− #[cfg(unix)]
274− {
275− use std::os::unix::fs::PermissionsExt;
276− std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?;
277− }
278− std::fs::rename(&temp, path).with_context(|| format!("could not write {}", path.display()))?;
279− Ok(())
280−}
281−
282281 // --- The computer -------------------------------------------------------------
283282
284283 /// Whether a process is still running.
655654 resources: saved.resources,
656655 capabilities,
657656 state_dir: dir.display().to_string(),
657+ warnings: warnings(dir),
658+ }
659+}
660+
661+// --- What weakens it ---------------------------------------------------------------
662+
663+/// What on this computer lets someone look inside the sandbox: a runner of
664+/// this account's that runs jobs as it, and a state folder others can read.
665+fn warnings(dir: &Path) -> Vec<String> {
666+ let mut said: Vec<String> = host_mode_runners(&host_runner_folders()).iter().map(|folder| host_mode_warning(folder)).collect();
667+ if dir.exists() {
668+ let readers = private::readers(dir);
669+ if !readers.is_empty() {
670+ said.push(format!(
671+ "{} can be read by {}: the runners' credentials and the VM's disk are in it. The next `g1t-runner sandbox up` makes it private, or refuses to start.",
672+ dir.display(),
673+ readers.join(", ")
674+ ));
675+ }
658676 }
677+ said
659678 }
660679
680+/// Where a runner of this account's may be registered: `g1t-runner`'s own
681+/// folder (`G1T_RUNNER_DIR`, `~/.g1t-runner`), and the desktop app's for
682+/// running jobs directly on the computer.
683+fn host_runner_folders() -> Vec<PathBuf> {
684+ let mut folders = vec![config::folder(None)];
685+ let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from);
686+ if let Some(home) = &home {
687+ folders.push(home.join(".g1t-runner"));
688+ }
689+ // The desktop app's data folder (its `app_data_dir`): runner/host.
690+ let data = match std::env::consts::OS {
691+ "windows" => std::env::var_os("APPDATA").map(PathBuf::from),
692+ "macos" => home.as_ref().map(|h| h.join("Library").join("Application Support")),
693+ _ => std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(PathBuf::from).or_else(|| home.as_ref().map(|h| h.join(".local").join("share"))),
694+ };
695+ if let Some(data) = data {
696+ folders.push(data.join("sh.g1t.desktop").join("runner").join("host"));
697+ }
698+ folders.dedup();
699+ folders
700+}
701+
702+/// Those of `folders` that hold a runner registered for host mode: jobs as
703+/// processes of this account, neither in a container nor in a VM.
704+fn host_mode_runners(folders: &[PathBuf]) -> Vec<PathBuf> {
705+ folders.iter().filter(|folder| config::load(folder).is_ok_and(|c| !c.docker && !c.native)).cloned().collect()
706+}
707+
708+fn host_mode_warning(folder: &Path) -> String {
709+ format!(
710+ "A runner registered in {} runs jobs directly on this computer, as you (host mode). Whenever it runs, what its jobs run can read this sandbox's runner credentials and reach inside the sandbox, as you can. Remove it (`g1t-runner remove --dir {}`), or register it under another account.",
711+ folder.display(),
712+ folder.display()
713+ )
714+}
715+
661716 /// The native capability's state: running while its runner lives;
662717 /// stopped when turned off, or registered and not running; `error` when
663718 /// it didn't start; `absent` before it is registered.
701756 }
702757 }
703758 }
759+ for warning in &status.warnings {
760+ println!("Warning: {warning}");
761+ }
704762 if let Some(error) = &status.error {
705763 println!("Error: {error}");
706764 }
771829 };
772830 let json = options.flag("json");
773831 let dir = options.value("state-dir").map(PathBuf::from).unwrap_or_else(|| config::folder(None).join("sandbox"));
832+ // The state folder holds the runners' credentials and the VM's disk:
833+ // only this account's, or nothing is made or started in it.
834+ let private = || private::folder(&dir, "--state-dir");
774835 let outcome = match args.first().map(String::as_str) {
775− Some("up") => up(&options, &dir),
776− Some("down") => down(&options, &dir),
777− Some("update") => update_sandbox(&options, &dir),
836+ Some("up") => private().and_then(|()| up(&options, &dir)),
837+ Some("down") => private().and_then(|()| down(&options, &dir)),
838+ Some("update") => private().and_then(|()| update_sandbox(&options, &dir)),
778839 Some("status") => Ok(()),
779840 _ => {
780841 println!("{HELP}");
889950 if local.runner_image.is_some() {
890951 saved.runner_image = local.runner_image.clone();
891952 }
892− // An update that stopped half-way left the registration here.
893− let carried = dir.join("carry").join("config.json");
894− if let Ok(bytes) = std::fs::read(&carried) {
895− backend.import_state(&bytes)?;
896− let _ = std::fs::remove_file(&carried);
897− }
898953 }
954+ // An update that stopped half-way, after the old system was
955+ // removed, left the registration here.
956+ let carried = dir.join("carry").join("config.json");
957+ if let Ok(bytes) = std::fs::read(&carried) {
958+ backend.import_state(&bytes)?;
959+ let _ = std::fs::remove_dir_all(dir.join("carry"));
960+ }
899961 if let Some(tar) = options.value("runner-image-file") {
900962 backend.load_image(Path::new(tar))?;
901963 }
10631125 log("Draining: the runner finishes the job it is running, then stops.");
10641126 backend.stop(StopMode::Drain)?;
10651127 }
1066− // The registration, kept on the computer until the new sandbox has it.
1067− let carried = dir.join("carry").join("config.json");
1128+ // The registration, kept on the computer (in the private state
1129+ // folder) only while no sandbox has it: from just before the old
1130+ // system goes until the new one has it. Should the update stop in
1131+ // between, the next `up` puts it in the new system and removes it.
1132+ let carry = dir.join("carry");
1133+ let carried = carry.join("config.json");
10681134 if let Some(bytes) = backend.export_state()? {
1069− std::fs::create_dir_all(dir.join("carry"))?;
1135+ std::fs::create_dir_all(&carry)?;
10701136 write_private(&carried, &bytes)?;
10711137 }
1072− backend.remove_system()?;
1138+ if let Err(error) = backend.remove_system() {
1139+ // The old system, and the registration in it, are still there.
1140+ let _ = std::fs::remove_dir_all(&carry);
1141+ return Err(error);
1142+ }
10731143 backend.create(&local, &saved.resources)?;
10741144 if let Ok(bytes) = std::fs::read(&carried) {
10751145 backend.import_state(&bytes)?;
1076− let _ = std::fs::remove_file(&carried);
10771146 }
1147+ let _ = std::fs::remove_dir_all(&carry);
10781148 if let Some(tar) = options.value("runner-image-file") {
10791149 backend.load_image(Path::new(tar))?;
10801150 }
11501220 ..Status::default()
11511221 };
11521222 let value = serde_json::to_value(&status).unwrap();
1153− for field in ["schema", "state", "backend", "runner_version", "launcher_version", "image_version", "isolation", "job", "error", "resources", "capabilities", "state_dir"] {
1223+ for field in ["schema", "state", "backend", "runner_version", "launcher_version", "image_version", "isolation", "job", "error", "resources", "capabilities", "state_dir", "warnings"] {
11541224 assert!(value.get(field).is_some(), "{field}");
11551225 }
11561226 for field in ["id", "available", "state", "isolation", "runner_version", "job", "cause", "fix", "turned_off"] {
12141284 }
12151285
12161286 #[test]
1287+ fn a_host_mode_runner_of_the_same_account_is_warned_of() {
1288+ let root = std::env::temp_dir().join(format!("g1t-sandbox-host-{}", std::process::id()));
1289+ let registered = |name: &str, how: &str| {
1290+ let folder = root.join(name);
1291+ std::fs::create_dir_all(&folder).unwrap();
1292+ let config = format!(
1293+ r#"{{"url":"https://g1t.sh","api":"https://api.g1t.sh","runner":"rnr_1","name":"a","credential":"g1tr_x","workspace":"acme","labels":[],"work_dir":"/tmp/w"{how}}}"#
1294+ );
1295+ std::fs::write(folder.join("config.json"), config).unwrap();
1296+ folder
1297+ };
1298+ let host = registered("host", r#","docker":false"#);
1299+ let docker = registered("docker", "");
1300+ let native = registered("native", r#","docker":false,"native":true"#);
1301+ let none = root.join("none");
1302+ let found = host_mode_runners(&[host.clone(), docker, native, none]);
1303+ assert_eq!(found.len(), 1);
1304+ assert_eq!(found[0], host);
1305+ let said = host_mode_warning(&host);
1306+ assert!(said.contains("as you") && said.contains("g1t-runner remove --dir"), "{said}");
1307+ // The desktop app's folder for running jobs directly is looked at.
1308+ assert!(host_runner_folders().iter().any(|f| f.ends_with(Path::new("sh.g1t.desktop").join("runner").join("host"))));
1309+ // An older status, without warnings, still reads.
1310+ let old: Status = serde_json::from_str(r#"{"schema":1,"state":"absent","backend":null,"runner_version":null,"launcher_version":"0.3.0","image_version":null,"isolation":null,"job":null,"error":null,"resources":{"cpus":0,"memory_mib":0,"disk_gib":0},"capabilities":[],"state_dir":"x"}"#).unwrap();
1311+ assert!(old.warnings.is_empty());
1312+ let _ = std::fs::remove_dir_all(&root);
1313+ }
1314+
1315+ #[test]
12171316 fn this_process_is_alive() {
12181317 assert!(process_alive(std::process::id()));
12191318 assert!(!process_alive(u32::MAX - 1));
+38−1
191191 Paths::in_dir(&self.dir.join("vm"))
192192 }
193193
194+ /// Takes the registration token out of the seed disk, once the runner
195+ /// has registered with it. The guest reads the seed only as it boots,
196+ /// and a token it lacks then is one it no longer needs.
197+ fn forget_token(&self) {
198+ let seed = self.paths().seed;
199+ let Some(mut settings) = std::fs::read(&seed).ok().and_then(|bytes| guest::read_seed(&bytes).ok()) else { return };
200+ if settings.token.take().is_some()
201+ && let Ok(bytes) = guest::write_seed(&settings, SEED_BYTES)
202+ {
203+ let _ = super::write_private(&seed, &bytes);
204+ }
205+ }
206+
194207 fn pid(&self) -> Option<u32> {
195208 std::fs::read_to_string(self.paths().pid).ok().and_then(|p| p.trim().parse().ok())
196209 }
335348 let log = std::fs::read(self.paths().console).ok()?;
336349 // The console's tail is enough: statuses come every few seconds.
337350 let tail = &log[log.len().saturating_sub(64 * 1024)..];
338− guest::status_from_console(&String::from_utf8_lossy(tail))
351+ let status = guest::status_from_console(&String::from_utf8_lossy(tail))?;
352+ // Registered: the token is no longer kept here either, as the
353+ // guest no longer keeps its own copy (guest.rs).
354+ if status.runner.is_some() {
355+ self.forget_token();
356+ }
357+ Some(status)
339358 }
340359
341360 fn stop(&self, mode: StopMode) -> Result<()> {
445464 }
446465
447466 #[test]
467+ fn the_seed_lets_go_of_the_token_once_the_runner_registered() {
468+ let dir = std::env::temp_dir().join(format!("g1t-vm-seed-{}", std::process::id()));
469+ let vm = DiskVm { hypervisor: Hypervisor::Qemu, dir: dir.clone() };
470+ std::fs::create_dir_all(dir.join("vm")).unwrap();
471+ let settings = Settings { url: Some("https://g1t.sh".into()), token: Some("g1trt_x".into()), runner_image: "img@sha256:ab".into(), ..Settings::default() };
472+ std::fs::write(vm.paths().seed, guest::write_seed(&settings, SEED_BYTES).unwrap()).unwrap();
473+ // The console says the runner is registered and running.
474+ let status = GuestStatus { state: "running".into(), runner: Some(crate::selfhosted::RunnerStatus::default()), ..GuestStatus::default() };
475+ std::fs::write(vm.paths().console, format!("boot\n{}{}\n", guest::STATUS_PREFIX, serde_json::to_string(&status).unwrap())).unwrap();
476+ assert!(vm.guest_status().is_some_and(|s| s.runner.is_some()));
477+ let kept = guest::read_seed(&std::fs::read(vm.paths().seed).unwrap()).unwrap();
478+ assert_eq!(kept.token, None);
479+ assert_eq!(kept.url.as_deref(), Some("https://g1t.sh"));
480+ assert_eq!(kept.runner_image, "img@sha256:ab");
481+ let _ = std::fs::remove_dir_all(&dir);
482+ }
483+
484+ #[test]
448485 fn a_vm_needs_its_three_files() {
449486 let vm = DiskVm { hypervisor: Hypervisor::Vfkit, dir: PathBuf::from("/s") };
450487 let keys = vm.image_keys();
+4−0
3030 mod exec;
3131 mod launcher;
3232 mod native;
33+mod private;
3334 mod sandbox;
3435 mod service;
3536 mod update;
454455 }
455456
456457 fn run(folder: &Path, once: bool) -> Result<()> {
458+ // Its folder holds its credential and, while a Windows job runs, that
459+ // job's script: a runner whose folder others can read does not start.
457460 let mut config = config::load(folder)?;
461+ private::folder(folder, "--dir")?;
458462 let mut api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
459463 std::fs::create_dir_all(&config.work_dir).with_context(|| format!("could not make {}", config.work_dir.display()))?;
460464 let isolation = isolation(config.docker, config.native)?;
+34−1
271271 folder.join("native").join("sandbox-id")
272272 }
273273
274−/// Stops the sandbox an earlier run of this runner left, if any.
274+/// Stops the sandbox an earlier run of this runner left, if any, and
275+/// removes the job folders it left: a runner stopped mid-job (a crash, or
276+/// `taskkill /F`) never cleaned its own, and a job's script holds the
277+/// job's token.
275278 pub fn sweep(folder: &Path) {
276279 let note = noted(folder);
277280 if let Ok(id) = std::fs::read_to_string(&note) {
281284 }
282285 let _ = std::fs::remove_file(&note);
283286 }
287+ for left in job_folders(&folder.join("native")) {
288+ let _ = std::fs::remove_dir_all(left);
289+ }
284290 }
285291
292+/// The job folders in a runner's `native` folder: `g1t-<job id>`, as
293+/// exec.rs names them.
294+pub fn job_folders(native: &Path) -> Vec<PathBuf> {
295+ let Ok(entries) = std::fs::read_dir(native) else { return Vec::new() };
296+ entries
297+ .flatten()
298+ .filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()) && entry.file_name().to_string_lossy().starts_with("g1t-"))
299+ .map(|entry| entry.path())
300+ .collect()
301+}
302+
286303 /// The ids in `wsb list --raw`.
287304 pub fn parse_list(raw: &str) -> Vec<String> {
288305 let Some(start) = raw.find('{') else { return Vec::new() };
387404 }
388405
389406 #[test]
407+ fn a_runner_stopped_mid_job_has_its_job_folders_swept() {
408+ let folder = std::env::temp_dir().join(format!("g1t-native-sweep-{}", std::process::id()));
409+ let native = folder.join("native");
410+ std::fs::create_dir_all(native.join("g1t-job_1")).unwrap();
411+ std::fs::write(native.join("g1t-job_1").join("job.ps1"), "${env:ACTIONS_TOKEN} = 't'").unwrap();
412+ std::fs::create_dir_all(native.join("native")).unwrap();
413+ std::fs::write(native.join("runner.log"), "").unwrap();
414+ assert_eq!(job_folders(&native), [native.join("g1t-job_1")]);
415+ // No sandbox was noted, so nothing is stopped; the job's folder goes.
416+ sweep(&folder);
417+ assert!(!native.join("g1t-job_1").exists());
418+ assert!(native.join("native").exists() && native.join("runner.log").exists(), "only job folders");
419+ let _ = std::fs::remove_dir_all(&folder);
420+ }
421+
422+ #[test]
390423 fn wsb_output_reads() {
391424 assert_eq!(parse_exit_code("{\n \"ExitCode\": 7\n}\n"), Some(7));
392425 assert_eq!(parse_exit_code("nothing"), None);
+360−0
1+//! The runner's folders, kept to the account that runs it. A runner's
2+//! folder holds its credential (`config.json`), a Windows job's script
3+//! while the job runs, and a sandbox's disk; anyone else on the computer
4+//! who could read them could act as the runner, or look at a job.
5+//!
6+//! On Unix a folder is `0700` and its files `0600`. On Windows a folder's
7+//! permissions come from the folder it is made in: in the user's profile
8+//! (the default, `~/.g1t-runner`) only its owner, SYSTEM and Administrators
9+//! can read it, but one made at `C:\runner` (`--dir`, `G1T_RUNNER_DIR`,
10+//! `--state-dir`) is readable by every user of the computer. So the folder
11+//! is made private when it is not, and the runner refuses to use one that
12+//! stays readable by others.
13+
14+use std::path::Path;
15+
16+use anyhow::{Context, Result, bail};
17+
18+/// Writes a file only its owner can read, through a temporary file, so a
19+/// crash never leaves half of it. On Windows its permissions are its
20+/// folder's ([`folder`]).
21+pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
22+ let temp = path.with_extension("new");
23+ std::fs::write(&temp, bytes).with_context(|| format!("could not write {}", temp.display()))?;
24+ #[cfg(unix)]
25+ {
26+ use std::os::unix::fs::PermissionsExt;
27+ std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?;
28+ }
29+ std::fs::rename(&temp, path).with_context(|| format!("could not write {}", path.display()))?;
30+ Ok(())
31+}
32+
33+/// Makes `dir` if it is not there, and makes sure only this account (and,
34+/// on Windows, SYSTEM and Administrators) can read it: tightened if it is
35+/// open to others, and refused if it stays so. `option` is how a person
36+/// picks another folder (`--dir`, `--state-dir`).
37+pub fn folder(dir: &Path, option: &str) -> Result<()> {
38+ std::fs::create_dir_all(dir).with_context(|| format!("could not make {}", dir.display()))?;
39+ let open = readers(dir);
40+ if open.is_empty() {
41+ return Ok(());
42+ }
43+ let tightened = tighten(dir);
44+ let still = readers(dir);
45+ if still.is_empty() {
46+ return Ok(());
47+ }
48+ let why = tightened.err().map(|e| format!(" ({e:#})")).unwrap_or_default();
49+ bail!(
50+ "{} can be read by {}, and could not be made private to this account{why}. It holds the runner's credential: {}, or pick a folder in your own profile with {option}",
51+ dir.display(),
52+ still.join(", "),
53+ fix(dir)
54+ )
55+}
56+
57+/// Who other than this account can read `dir`, as a person names them;
58+/// empty when no one.
59+pub fn readers(dir: &Path) -> Vec<String> {
60+ #[cfg(unix)]
61+ {
62+ use std::os::unix::fs::PermissionsExt;
63+ let Ok(meta) = std::fs::metadata(dir) else { return Vec::new() };
64+ unix_readers(meta.permissions().mode())
65+ }
66+ #[cfg(windows)]
67+ {
68+ match windows::sddl(dir) {
69+ Some(sddl) => broad_readers(&sddl),
70+ None => Vec::new(),
71+ }
72+ }
73+}
74+
75+#[cfg(unix)]
76+fn tighten(dir: &Path) -> Result<()> {
77+ use std::os::unix::fs::PermissionsExt;
78+ std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).with_context(|| format!("could not chmod {}", dir.display()))
79+}
80+
81+#[cfg(windows)]
82+fn tighten(dir: &Path) -> Result<()> {
83+ let Some(user) = windows::user_sid() else { bail!("could not tell which account this is") };
84+ for args in icacls_args(&dir.display().to_string(), &user) {
85+ let mut command = std::process::Command::new("icacls");
86+ command.args(&args).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::piped());
87+ super::native::hide_window(&mut command);
88+ let output = command.output().context("could not run icacls")?;
89+ if !output.status.success() {
90+ bail!("icacls: {}", String::from_utf8_lossy(&output.stderr).trim());
91+ }
92+ }
93+ Ok(())
94+}
95+
96+fn fix(dir: &Path) -> String {
97+ if cfg!(windows) {
98+ format!("in {}'s Properties, Security, Advanced, turn off inheritance and leave only yourself, SYSTEM and Administrators", dir.display())
99+ } else {
100+ format!("run `chmod 700 {}` as its owner", dir.display())
101+ }
102+}
103+
104+/// Who a Unix folder's mode lets in besides its owner.
105+#[cfg_attr(not(unix), allow(dead_code))]
106+fn unix_readers(mode: u32) -> Vec<String> {
107+ let mut who = Vec::new();
108+ if mode & 0o040 != 0 {
109+ who.push("its group".to_owned());
110+ }
111+ if mode & 0o004 != 0 {
112+ who.push("every user".to_owned());
113+ }
114+ who
115+}
116+
117+/// The well-known groups that mean every (or any) user of a Windows
118+/// computer, by SID and by SDDL's short name.
119+#[cfg_attr(not(windows), allow(dead_code))]
120+const BROAD: &[(&str, &str, &str)] = &[
121+ ("S-1-1-0", "WD", "Everyone"),
122+ ("S-1-5-32-545", "BU", "Users"),
123+ ("S-1-5-11", "AU", "Authenticated Users"),
124+ ("S-1-5-4", "IU", "Interactive users"),
125+ ("S-1-5-7", "AN", "Anonymous"),
126+ ("S-1-5-32-546", "BG", "Guests"),
127+];
128+
129+/// Who of [`BROAD`] a folder's DACL, in SDDL (`D:PAI(A;OICI;FA;;;SY)…`),
130+/// lets read it. A folder with no DACL at all lets everyone in.
131+#[cfg_attr(not(windows), allow(dead_code))]
132+fn broad_readers(sddl: &str) -> Vec<String> {
133+ let Some(start) = sddl.find("D:") else { return vec!["Everyone".to_owned()] };
134+ let dacl = &sddl[start + 2..];
135+ // The SACL, if it was asked for, follows the DACL.
136+ let dacl = dacl.find("S:").map_or(dacl, |end| &dacl[..end]);
137+ if dacl.starts_with("NO_ACCESS_CONTROL") {
138+ return vec!["Everyone".to_owned()];
139+ }
140+ let mut who = Vec::new();
141+ for ace in dacl.split('(').skip(1).filter_map(|rest| rest.split(')').next()) {
142+ let fields: Vec<&str> = ace.split(';').collect();
143+ let [kind, _flags, rights, _, _, sid, ..] = fields[..] else { continue };
144+ if !matches!(kind, "A" | "XA") || !reads(rights) {
145+ continue;
146+ }
147+ if let Some((_, _, name)) = BROAD.iter().find(|(long, short, _)| sid.eq_ignore_ascii_case(long) || sid == *short)
148+ && !who.iter().any(|w| w == name)
149+ {
150+ who.push((*name).to_owned());
151+ }
152+ }
153+ who
154+}
155+
156+/// Whether an ACE's rights, in SDDL, include reading a file's contents.
157+#[cfg_attr(not(windows), allow(dead_code))]
158+fn reads(rights: &str) -> bool {
159+ // FILE_READ_DATA, GENERIC_ALL, GENERIC_READ.
160+ const READ: u32 = 0x1 | 0x1000_0000 | 0x8000_0000;
161+ if let Some(hex) = rights.strip_prefix("0x").or_else(|| rights.strip_prefix("0X")) {
162+ return u32::from_str_radix(hex, 16).is_ok_and(|mask| mask & READ != 0);
163+ }
164+ if let Ok(mask) = rights.parse::<u32>() {
165+ return mask & READ != 0;
166+ }
167+ // Two letters a right: `FA` all, `FR` read, `GA`/`GR` generic all and
168+ // read, `CC` (bit 0, which for a file is reading its data).
169+ rights.as_bytes().chunks(2).any(|code| matches!(code, b"FA" | b"FR" | b"GA" | b"GR" | b"KA" | b"KR" | b"CC"))
170+}
171+
172+/// The `icacls` commands that leave a folder to `user`, SYSTEM and
173+/// Administrators only, inherited by what is in it: no permissions from
174+/// the folder above, and none for the groups that mean everyone.
175+#[cfg_attr(not(windows), allow(dead_code))]
176+fn icacls_args(dir: &str, user: &str) -> Vec<Vec<String>> {
177+ let mut grant = vec![dir.to_owned(), "/inheritance:r".into(), "/grant:r".into()];
178+ for sid in ["S-1-5-18", "S-1-5-32-544", user] {
179+ grant.push(format!("*{sid}:(OI)(CI)F"));
180+ }
181+ let mut remove = vec![dir.to_owned(), "/remove:g".into()];
182+ remove.extend(BROAD.iter().map(|(sid, _, _)| format!("*{sid}")));
183+ vec![grant, remove]
184+}
185+
186+#[cfg(windows)]
187+mod windows {
188+ //! Reading a folder's permissions and this process's account, from
189+ //! Windows' own calls: SDDL and SIDs, which are the same in every
190+ //! language, where `icacls`' listing names groups in the computer's.
191+
192+ use std::os::windows::ffi::OsStrExt;
193+ use std::path::Path;
194+ use std::ptr::null_mut;
195+
196+ type Handle = *mut core::ffi::c_void;
197+
198+ const SE_FILE_OBJECT: i32 = 1;
199+ const DACL_SECURITY_INFORMATION: u32 = 4;
200+ const SDDL_REVISION_1: u32 = 1;
201+ const TOKEN_QUERY: u32 = 8;
202+ const TOKEN_USER: i32 = 1;
203+
204+ #[link(name = "advapi32")]
205+ unsafe extern "system" {
206+ fn GetNamedSecurityInfoW(
207+ name: *const u16,
208+ object: i32,
209+ info: u32,
210+ owner: *mut Handle,
211+ group: *mut Handle,
212+ dacl: *mut Handle,
213+ sacl: *mut Handle,
214+ descriptor: *mut Handle,
215+ ) -> u32;
216+ fn ConvertSecurityDescriptorToStringSecurityDescriptorW(descriptor: Handle, revision: u32, info: u32, text: *mut *mut u16, length: *mut u32) -> i32;
217+ fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32;
218+ fn GetTokenInformation(token: Handle, class: i32, info: *mut core::ffi::c_void, length: u32, returned: *mut u32) -> i32;
219+ fn ConvertSidToStringSidW(sid: Handle, text: *mut *mut u16) -> i32;
220+ }
221+
222+ unsafe extern "system" {
223+ fn LocalFree(memory: Handle) -> Handle;
224+ fn GetCurrentProcess() -> Handle;
225+ fn CloseHandle(handle: Handle) -> i32;
226+ }
227+
228+ /// A string Windows allocated, read and freed.
229+ unsafe fn take(text: *mut u16) -> String {
230+ let mut length = 0;
231+ while unsafe { *text.add(length) } != 0 {
232+ length += 1;
233+ }
234+ let read = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, length) });
235+ unsafe { LocalFree(text.cast()) };
236+ read
237+ }
238+
239+ /// A file or folder's DACL, in SDDL.
240+ pub fn sddl(path: &Path) -> Option<String> {
241+ let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
242+ let mut descriptor: Handle = null_mut();
243+ let error = unsafe {
244+ GetNamedSecurityInfoW(wide.as_ptr(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, null_mut(), null_mut(), null_mut(), null_mut(), &mut descriptor)
245+ };
246+ if error != 0 || descriptor.is_null() {
247+ return None;
248+ }
249+ let mut text: *mut u16 = null_mut();
250+ let converted = unsafe { ConvertSecurityDescriptorToStringSecurityDescriptorW(descriptor, SDDL_REVISION_1, DACL_SECURITY_INFORMATION, &mut text, null_mut()) };
251+ unsafe { LocalFree(descriptor) };
252+ (converted != 0 && !text.is_null()).then(|| unsafe { take(text) })
253+ }
254+
255+ /// This process's account's SID: `S-1-5-21-…`.
256+ pub fn user_sid() -> Option<String> {
257+ let mut token: Handle = null_mut();
258+ if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
259+ return None;
260+ }
261+ // TOKEN_USER and the SID after it; usize keeps its pointer aligned.
262+ let mut buffer = vec![0usize; 64];
263+ let mut returned = 0u32;
264+ let ok = unsafe { GetTokenInformation(token, TOKEN_USER, buffer.as_mut_ptr().cast(), (buffer.len() * size_of::<usize>()) as u32, &mut returned) };
265+ unsafe { CloseHandle(token) };
266+ if ok == 0 {
267+ return None;
268+ }
269+ // TOKEN_USER begins with the SID's pointer.
270+ let sid = buffer[0] as Handle;
271+ let mut text: *mut u16 = null_mut();
272+ if unsafe { ConvertSidToStringSidW(sid, &mut text) } == 0 || text.is_null() {
273+ return None;
274+ }
275+ Some(unsafe { take(text) })
276+ }
277+}
278+
279+#[cfg(test)]
280+mod tests {
281+ use super::*;
282+
283+ #[test]
284+ fn a_folder_open_to_every_user_is_found_by_its_groups() {
285+ // C:\runner, made under C:\: Users read it, Authenticated Users change it.
286+ let open = "D:AI(A;OICIID;FA;;;BA)(A;OICIID;FA;;;SY)(A;OICIID;0x1200a9;;;BU)(A;ID;0x1301bf;;;AU)(A;OICIIOID;SDGXGWGR;;;AU)";
287+ assert_eq!(broad_readers(open), ["Users", "Authenticated Users"]);
288+ // The user's profile, or a folder made private.
289+ let private = "D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;S-1-5-21-1-2-3-1001)";
290+ assert!(broad_readers(private).is_empty());
291+ // Everyone, by SID; and a right to list and traverse only, which reads nothing.
292+ assert_eq!(broad_readers("D:(A;;0x120089;;;S-1-1-0)"), ["Everyone"]);
293+ assert!(broad_readers("D:(A;;0x100020;;;WD)").is_empty());
294+ // A deny is no way in, and no DACL at all is every way in.
295+ assert!(broad_readers("D:(D;;FA;;;WD)").is_empty());
296+ assert_eq!(broad_readers("D:NO_ACCESS_CONTROL"), ["Everyone"]);
297+ assert_eq!(broad_readers("O:BAG:SY"), ["Everyone"]);
298+ }
299+
300+ #[test]
301+ fn rights_that_read_a_file_are_told_from_those_that_do_not() {
302+ assert!(reads("FA") && reads("FR") && reads("GR") && reads("0x1200a9") && reads("0x80000000") && reads("1"));
303+ assert!(!reads("FX") && !reads("SDGXGW") && !reads("0x100020") && !reads(""));
304+ }
305+
306+ #[test]
307+ fn a_folder_is_left_to_its_owner_system_and_administrators() {
308+ let [grant, remove] = &icacls_args(r"C:\runner", "S-1-5-21-1-2-3-1001")[..] else { panic!() };
309+ assert_eq!(grant, &[r"C:\runner", "/inheritance:r", "/grant:r", "*S-1-5-18:(OI)(CI)F", "*S-1-5-32-544:(OI)(CI)F", "*S-1-5-21-1-2-3-1001:(OI)(CI)F"]);
310+ assert_eq!(remove[..2], [r"C:\runner", "/remove:g"]);
311+ for sid in ["*S-1-1-0", "*S-1-5-32-545", "*S-1-5-11"] {
312+ assert!(remove.iter().any(|a| a == sid), "{sid}");
313+ }
314+ }
315+
316+ #[test]
317+ fn a_unix_folder_is_open_when_its_group_or_others_may_read_it() {
318+ assert!(unix_readers(0o700).is_empty());
319+ assert!(unix_readers(0o711).is_empty());
320+ assert_eq!(unix_readers(0o750), ["its group"]);
321+ assert_eq!(unix_readers(0o755), ["its group", "every user"]);
322+ }
323+
324+ #[test]
325+ fn a_folder_the_runner_makes_is_private() {
326+ let dir = std::env::temp_dir().join(format!("g1t-private-{}", std::process::id())).join("state");
327+ folder(&dir, "--dir").unwrap();
328+ assert!(readers(&dir).is_empty(), "{:?}", readers(&dir));
329+ write_private(&dir.join("config.json"), b"{}").unwrap();
330+ assert_eq!(std::fs::read(dir.join("config.json")).unwrap(), b"{}");
331+ #[cfg(unix)]
332+ {
333+ use std::os::unix::fs::PermissionsExt;
334+ assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
335+ assert_eq!(std::fs::metadata(dir.join("config.json")).unwrap().permissions().mode() & 0o777, 0o600);
336+ }
337+ #[cfg(windows)]
338+ assert!(windows::user_sid().is_some_and(|sid| sid.starts_with("S-1-")));
339+ let _ = std::fs::remove_dir_all(dir.parent().unwrap());
340+ }
341+
342+ /// Makes a folder every user can read and tightens it: run with
343+ /// `cargo test -p g1t-runner -- --ignored a_folder_every_user_can_read`
344+ /// on Windows (it uses `C:\Users\Public`).
345+ #[test]
346+ #[ignore]
347+ #[cfg(windows)]
348+ fn a_folder_every_user_can_read_is_made_private() {
349+ let dir = std::path::PathBuf::from(format!(r"C:\Users\Public\g1t-private-{}", std::process::id()));
350+ std::fs::create_dir_all(&dir).unwrap();
351+ assert!(!readers(&dir).is_empty(), "Public is open to every user");
352+ folder(&dir, "--dir").unwrap();
353+ assert!(readers(&dir).is_empty(), "{:?}", windows::sddl(&dir));
354+ let sddl = windows::sddl(&dir).unwrap();
355+ assert!(sddl.starts_with("D:P"), "no permissions from the folder above: {sddl}");
356+ std::fs::write(dir.join("config.json"), "{}").unwrap();
357+ assert!(readers(&dir.join("config.json")).is_empty(), "{:?}", windows::sddl(&dir.join("config.json")));
358+ std::fs::remove_dir_all(&dir).unwrap();
359+ }
360+}
+6−0
5050 RUN echo "$IMAGE_VERSION" > /etc/g1t-sandbox-version
5151
5252 FROM base AS wsl
53+# WSL's default user (wsl.conf): a locked account with no shell and no
54+# home, so whatever opens the distribution without naming a user (Explorer
55+# at \\wsl.localhost\g1t-sandbox, a bare `wsl -d g1t-sandbox`) is not root
56+# and cannot read the runner's registration. The launcher names root itself.
57+RUN useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin sandbox \
58+ && passwd --lock sandbox
5359 COPY wsl.conf /etc/wsl.conf
5460
5561 FROM base AS vm
+5−1
1515 [boot]
1616 systemd = false
1717
18+# Every call the launcher makes names root itself (`wsl -u root`). Anything
19+# else that opens the distribution, a `wsl -d g1t-sandbox` with no user or
20+# Explorer at \\wsl.localhost\g1t-sandbox, gets this account instead: no
21+# password, no shell, no home, and none of root's files (the Dockerfile).
1822 [user]
19−default = root
23+default = sandbox
2024
2125 # WSL's resolv.conf is a link into /mnt/wsl, which the sandbox unmounts:
2226 # the supervisor writes its own from what WSL offers at start.
+90−3
3030 /// The most a single log report may add.
3131 const MAX_CHUNK_BYTES: usize = 256 * 1024;
3232 const MAX_ANNOTATIONS: usize = 50;
33+/// Swaps a running job's token for a new one, only while it still has the
34+/// one the caller showed (`job_spec` with `rotate`).
35+const TOKEN_ROTATE: &str = "UPDATE jobs SET token_hash = ?1 WHERE id = ?2 AND status = 'in_progress' AND token_hash = ?3 RETURNING id";
3336 /// A batch of a job's log, under the next `seq` of that job's, worked out
3437 /// in the same statement (SQLite runs one write at a time). `?4` is the
3538 /// runner's number for the batch (null from runners that send none): a
22762279 /// `job_spec`: everything the sandbox needs to run the job.
22772280 pub async fn job_spec(&self, a: JobCallArgs) -> Result<Outcome<Value>> {
22782281 let job = check!(self.job_for_token(&a).await?);
2282+ // Asked to, the token the harness asked with is spent here: it gets
2283+ // a new one with the spec, for everything after, so a copy of the
2284+ // first left on the runner's computer (a Windows job's script, a
2285+ // container's environment) cannot fetch the secrets again. The new
2286+ // token is made now, as the runtime token is signed with its hash,
2287+ // and swapped in last, so a spec that could not be made leaves the
2288+ // old one working.
2289+ let next = a.rotate.then(|| random_hex(24));
2290+ let token_hash = match &next {
2291+ Some(next) => sha256_hex(next),
2292+ None => job.token_hash.clone().unwrap_or_default(),
2293+ };
22792294 let Some(run) = self.run_row(&job.run_id).await? else {
22802295 return Ok(fail(FailureCode::NotFound, "No such run."));
22812296 };
23982413 let runtime_token = crate::runtime::runtime_token(
23992414 &job.id,
24002415 &job.run_id,
2401− job.token_hash.as_deref().unwrap_or_default(),
2416+ &token_hash,
24022417 now_ms() / 1000,
24032418 u64::from(job.timeout_minutes) * 60 + 600,
24042419 );
24692484 _ => format!("{SITE}/{}.git", run.repo),
24702485 };
24712486
2472− Ok(Outcome::Ok(json!({
2487+ // Only one fetch with a token gets its spec: of two at once, the
2488+ // second finds the hash already changed and is refused.
2489+ if next.is_some() {
2490+ let swapped = self
2491+ .db
2492+ .prepare(TOKEN_ROTATE)
2493+ .bind(&[token_hash.as_str().into(), job.id.as_str().into(), job.token_hash.as_deref().unwrap_or_default().into()])?
2494+ .first::<Value>(None)
2495+ .await?;
2496+ if swapped.is_none() {
2497+ return Ok(fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."));
2498+ }
2499+ }
2500+
2501+ let mut spec = json!({
24732502 "job": job.id,
24742503 "run": run.id,
24752504 "key": job.key,
25132542 "token": runtime_token,
25142543 "idToken": self.oidc_allowed(&run, &job),
25152544 },
2516− })))
2545+ });
2546+ if let Some(next) = next {
2547+ spec["nextToken"] = json!(next);
2548+ }
2549+ Ok(Outcome::Ok(spec))
25172550 }
25182551
25192552 /// `job_report`: the sandbox telling how the job is going.
28392872 }
28402873
28412874 #[cfg(test)]
2875+mod token_rotation {
2876+ use g1t_secrets::sha256_hex;
2877+ use rusqlite::{Connection, OptionalExtension, params};
2878+
2879+ use super::TOKEN_ROTATE;
2880+
2881+ fn database() -> Connection {
2882+ let db = Connection::open_in_memory().unwrap();
2883+ let path = format!("{}/migrations/0001_init.sql", env!("CARGO_MANIFEST_DIR"));
2884+ db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
2885+ db.execute(
2886+ "INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, status, token_hash) VALUES ('job_1', 'run_1', 'repo_1', 'acme', 'build', 'build', 'in_progress', ?1)",
2887+ [sha256_hex("first")],
2888+ )
2889+ .unwrap();
2890+ db
2891+ }
2892+
2893+ fn rotate(db: &Connection, next: &str, shown: &str) -> bool {
2894+ db.query_row(TOKEN_ROTATE, params![sha256_hex(next), "job_1", sha256_hex(shown)], |row| row.get::<_, String>(0))
2895+ .optional()
2896+ .unwrap()
2897+ .is_some()
2898+ }
2899+
2900+ fn hash(db: &Connection) -> Option<String> {
2901+ db.query_row("SELECT token_hash FROM jobs WHERE id = 'job_1'", [], |row| row.get(0)).unwrap()
2902+ }
2903+
2904+ #[test]
2905+ fn a_job_token_fetches_its_spec_once() {
2906+ let db = database();
2907+ assert!(rotate(&db, "second", "first"));
2908+ assert_eq!(hash(&db), Some(sha256_hex("second")));
2909+ // The first token, shown again (a copy left on the computer, or a
2910+ // second fetch racing the first), swaps nothing and is refused.
2911+ assert!(!rotate(&db, "third", "first"));
2912+ assert_eq!(hash(&db), Some(sha256_hex("second")));
2913+ // A job that ended has no token to swap.
2914+ db.execute("UPDATE jobs SET status = 'completed', token_hash = NULL", []).unwrap();
2915+ assert!(!rotate(&db, "fourth", "second"));
2916+ assert_eq!(hash(&db), None);
2917+ }
2918+
2919+ #[test]
2920+ fn the_runtime_token_is_signed_for_the_token_that_replaces_the_first() {
2921+ let now = 1_800_000_000;
2922+ let token = crate::runtime::runtime_token("job_1", "run_1", &sha256_hex("second"), now, 600);
2923+ assert!(crate::runtime::runtime_valid(&token, "job_1", &sha256_hex("second"), now));
2924+ assert!(!crate::runtime::runtime_valid(&token, "job_1", &sha256_hex("first"), now));
2925+ }
2926+}
2927+
2928+#[cfg(test)]
28422929 mod stopping {
28432930 use super::stops_runs;
28442931 use g1t_contracts::events::Event;

Comments

Sign in to comment.