vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Give a job a credential for the bot it acts as
6e1fca5115b709d31eeb51864b187d79aa104e5f · Keenan Nemetz · 6d ago · parent 446542e6
Give a job a credential for the bot it acts as A workflow can name a bot account with `bot:`. When the job is leased the instance generates a key for that run, stores the public half with the run, repository and expiry, and sends the private half on the lease channel. The runner writes it into the job's run directory with a known_hosts entry for the instance, so a step's `git push` or `vole issue comment` authenticates as the account and nothing has to be wired per job. The account's role is the permission, in full. The credential replaces exactly one requirement — that writing to the issues branch needs `maintain` — and adds no rule of its own. What a job may write there is what its account may do, asked the same way the browser asks: discuss, file an issue, manage an item it wrote, open a change from a branch it may push, triage and approve when the role allows it. Those are the ACL's own capabilities (`CanDiscuss`, `CanOpenRequest`, `CanTriage`, `CanApprove`), so a maintainer's job may do everything a maintainer's key may do, including labels, states, and other people's items. A session is scoped only when the fingerprint it authenticated with is a live credential row; an expired one reads as absent, so the push falls back to the account's own role and is refused where a contribute account cannot write. Two things end the key: the run finishing, which drops the row, and the expiry, which is what stops a credential whose runner was killed and never reported. The key is also masked in the log like a secret, since a step that cats it should not leave it in the attempt. Closes #165.
ci/agent/agent.go +52−5
| @@ -85,6 +85,16 @@ type runnerJob struct { | |||
| 85 | 85 | // The runner starts the clock with the first step and cuts every later step short | |
| 86 | 86 | // when the budget runs out. | |
| 87 | 87 | Timeout string `json:"timeout,omitempty"` | |
| 88 | // Bot is the account this job acts as, and Credential is the key it may act with. | ||
| 89 | // The key is written into the run directory and is valid until the run ends. | ||
| 90 | Bot string `json:"bot,omitempty"` | ||
| 91 | Credential *runnerCredential `json:"credential,omitempty"` | ||
| 92 | // CredentialError means the job asked for an account it cannot act as. The runner | ||
| 93 | // fails the job before the container starts rather than running it without a key. | ||
| 94 | CredentialError string `json:"credential_error,omitempty"` | ||
| 95 | // HostKey is the instance's public host key line, so the job can verify the host | ||
| 96 | // it pushes to without scanning it. | ||
| 97 | HostKey string `json:"host_key,omitempty"` | ||
| 88 | 98 | // Image is a registry reference or "file:<name>". For the latter, ImageDefinition | |
| 89 | 99 | // is the pinned recipe and ImageSHA its digest; the runner builds it locally. | |
| 90 | 100 | Image string `json:"image"` | |
| @@ -111,6 +121,12 @@ type runnerJob struct { | |||
| 111 | 121 | Pages *runnerPages `json:"pages,omitempty"` | |
| 112 | 122 | } | |
| 113 | 123 | ||
| 124 | // runnerCredential is the private half of a run's key, delivered once. | ||
| 125 | type runnerCredential struct { | ||
| 126 | PrivateKey string `json:"private_key"` | ||
| 127 | Fingerprint string `json:"fingerprint"` | ||
| 128 | } | ||
| 129 | |||
| 114 | 130 | // runnerSecret is one delivered value and the version it came from. The version is | |
| 115 | 131 | // what the attempt record reports, so a result says which value ran without | |
| 116 | 132 | // carrying it. | |
| @@ -564,6 +580,12 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur | |||
| 564 | 580 | fmt.Fprintf(log, "\n%s\n", msg) | |
| 565 | 581 | s.finish(remote, co, job, log, 1, nil, nil, nil, box) | |
| 566 | 582 | } | |
| 583 | // A job that asked to act as an account it cannot act as is broken as written, not | ||
| 584 | // degraded: fail it before the image is pulled and say why. | ||
| 585 | if job.CredentialError != "" { | ||
| 586 | fail("%s", job.CredentialError) | ||
| 587 | return | ||
| 588 | } | ||
| 567 | 589 | fmt.Fprintf(log, "=== %s · %s · %s @ %s\n", job.Repo, job.Job, job.Branch, format.SHA(job.SHA)) | |
| 568 | 590 | // The job's network request is resolved before anything runs, so a runner that was | |
| 569 | 591 | // handed a job it cannot isolate refuses it instead of leaking the network into a | |
| @@ -603,7 +625,7 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur | |||
| 603 | 625 | // an argument at all. | |
| 604 | 626 | env := job.Env | |
| 605 | 627 | runDir := "" | |
| 606 | if job.EventPayload != "" || len(job.Secrets) > 0 { | ||
| 628 | if job.EventPayload != "" || len(job.Secrets) > 0 || job.Credential != nil { | ||
| 607 | 629 | runDir = filepath.Join(filepath.Dir(co.Work), "run-"+job.ID+"-"+fmt.Sprint(job.Attempt)) | |
| 608 | 630 | if err := os.MkdirAll(runDir, 0o700); err != nil { | |
| 609 | 631 | fail("cannot prepare the run directory: %s", err) | |
| @@ -638,6 +660,29 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur | |||
| 638 | 660 | } | |
| 639 | 661 | secretsFile = "/run/vole/secrets.env" | |
| 640 | 662 | } | |
| 663 | if job.Credential != nil { | ||
| 664 | // The key is a file, not a value in the environment, so `git push` finds it by | ||
| 665 | // path and no step has to interpolate it. The known_hosts entry comes from the | ||
| 666 | // lease: the job cannot scan the host without trusting whatever answers. | ||
| 667 | if env, err = withCredentialEnv(env, remote, job); err != nil { | ||
| 668 | fail("%s", err) | ||
| 669 | return | ||
| 670 | } | ||
| 671 | if err := os.WriteFile(filepath.Join(runDir, "id_bot"), []byte(job.Credential.PrivateKey), 0o600); err != nil { | ||
| 672 | fail("cannot write the run credential: %s", err) | ||
| 673 | return | ||
| 674 | } | ||
| 675 | known, err := knownHostsLine(remote, job.HostKey) | ||
| 676 | if err != nil { | ||
| 677 | fail("%s", err) | ||
| 678 | return | ||
| 679 | } | ||
| 680 | if err := os.WriteFile(filepath.Join(runDir, "known_hosts"), []byte(known), 0o644); err != nil { | ||
| 681 | fail("cannot write the known_hosts entry: %s", err) | ||
| 682 | return | ||
| 683 | } | ||
| 684 | fmt.Fprintf(log, "acting as %s for this run\n", job.Bot) | ||
| 685 | } | ||
| 641 | 686 | if s.cacheRoot != "" { | |
| 642 | 687 | defer func() { | |
| 643 | 688 | if !s.cacheReuse { | |
| @@ -1181,13 +1226,15 @@ func (s *runnerSession) runCommand(ctx context.Context, cmd *exec.Cmd, runID str | |||
| 1181 | 1226 | // secretValues is what the log has to redact: the values this job was actually | |
| 1182 | 1227 | // given, not the names it asked for. | |
| 1183 | 1228 | func secretValues(job *runnerJob) []string { | |
| 1184 | if len(job.Secrets) == 0 { | ||
| 1185 | return nil | ||
| 1186 | } | ||
| 1187 | values := make([]string, 0, len(job.Secrets)) | ||
| 1229 | values := make([]string, 0, len(job.Secrets)+1) | ||
| 1188 | 1230 | for _, delivered := range job.Secrets { | |
| 1189 | 1231 | values = append(values, delivered.Value) | |
| 1190 | 1232 | } | |
| 1233 | // The run credential is a secret in the same sense: short-lived and scoped, but a | ||
| 1234 | // step that cats the key should not leave it in the log the instance keeps. | ||
| 1235 | if job.Credential != nil && job.Credential.PrivateKey != "" { | ||
| 1236 | values = append(values, job.Credential.PrivateKey) | ||
| 1237 | } | ||
| 1191 | 1238 | return values | |
| 1192 | 1239 | } | |
| 1193 | 1240 | ||