Why
Part 4 of #138 names secrets as the biggest blocker for deploy and publish jobs, and today the only secret Vole stores is the SSH host key. A job cannot be given a registry token, a deploy key, or a staging password without that value becoming workflow content, a shell argument, or a file on a runner that nobody rotates and nobody can audit.
This is the design for a small encrypted store that a workflow declares and a
runner receives, together with the two ways people use it: vole secret get for a
developer who needs the staging key, and a write-only form on the repository’s
settings page.
The store
data/secrets.db (SQLite, 0600) beside data/users.db, plus data/secrets.key
(32 random bytes, 0600, created on first use, path overridable with
secrets_key in vole.toml).
secrets(repo, name, version, sealed, created_by, created, updated_by, updated, last_read)
PRIMARY KEY (repo, name)
sealed is AES-256-GCM with a random nonce and AAD repo \0 name \0 version, so a
row cannot be moved to another name or replayed as an older version. A format byte
in front of the blob leaves room for key rotation without a migration. set
replaces the value and increments version; old values are not kept.
Repository scope only. A value a team shares across repositories is set in one repository and read by that repository’s maintainers. Instance-wide secrets would need a second access model for one store and are not worth it yet.
Say plainly what the encryption buys: the file mode is the real boundary, and the
key mainly protects copies of data/ that leave the host — a backup, an rsync, a
stray tarball. A key stored beside the database in the default configuration is
not a defence against someone who has the directory.
The workflow field
Job scope, because a job is one container: a step-scoped value would keep the name out of a step, not the value.
jobs:
deploy:
image: alpine:3.22
secrets: [DEPLOY_KEY, SENTRY_DSN]
steps:
- run: ./deploy.sh # reads ${DEPLOY_KEY:?} and ${SENTRY_DSN:-none}
Declaring a name is what makes it present. A job that does not list a secret does not receive it, so a workflow a maintainer approved is also the list of values its steps can see.
There is no interpolation and no ${{ }}: a secret arrives as an environment
variable and nothing else.
Delivery
- When the run is queued, the row pins the declared names, beside
EnvandArtifacts. No value is stored inruns.db. - When the job is leased, the server resolves the names it has and sends
{name, version, value}in the lease reply — the same authenticated SSH channel that already carries the steps and the image definition. - The runner writes
export NAME='…'lines, POSIX single-quoted so a'becomes'\''and newlines survive exactly, into a0600file in the run’s directory and mounts that directory at/run/vole, where event payloads already live. - Every step exec is prefixed with
. /run/vole/secrets.env, so the values are exported into the step’s shell and reach the processes it starts. - The finish message reports
{name, version}for what was delivered, so the attempt record says which value ran without holding it.
--env-file was the obvious choice and does not work: the format is one
NAME=VALUE per line, so a PEM key would be truncated silently. --env NAME=value
carries newlines but puts the value in the runtime’s argv, where ps on the runner
host shows it. Sourcing a file avoids both, keeps every value out of every argv,
and needs nothing runtime-specific.
A side effect worth knowing: only the step shells and their children see the values, not PID 1 and not a background process an earlier step left running. That is slightly tighter than container-wide environment.
When a secret is missing
There is no separate “required” flag. A name that does not resolve is simply left out, the variable is unset rather than empty, and the runner writes one line to the attempt log before the first step:
vole: DEPLOY_KEY is not set for demo, so this job runs without it.
An optional secret stays optional, and a required one is made required by the step
— ${DEPLOY_KEY:?DEPLOY_KEY is not set}, or set -u — which puts the message in
the hands of whoever wrote the step. vole workflow run prints the same line.
Limits
The values end up as environment variables of the step’s processes, so the limits are the kernel’s, enforced early enough that the message is ours:
| Limit | Value | Enforced at |
|---|---|---|
| One secret | len(name) + 1 + len(value) ≤ 128 KiB |
set / save |
| A value’s bytes | valid UTF-8, no NUL | set / save |
| All secrets in one job | Σ len(name) + 1 + len(value) ≤ 2 MiB |
lease |
128 KiB is MAX_ARG_STRLEN, the kernel’s ceiling for one NAME=value string in
envp, so a longer value could never reach a process. 2 MiB is the default
ARG_MAX, RLIMIT_STACK / 4. A value over the first limit is refused with that
reason; values that do not fit in the second are withheld in declaration order and
get the warning above, because a step that quietly ran without its token is worse
than one that was told.
Invalid UTF-8 is refused because the lease channel is JSON and Go’s encoder
replaces an invalid byte with U+FFFD, which would corrupt a secret silently; a NUL
cannot be passed to a process at all. There is no limit on how many secrets a
repository has, how many a job declares, or how much secrets.db holds.
Masking
The runner masks every delivered value in the log before a chunk is uploaded, so
data/logs/ never holds one. The streamer keeps a carry of the longest value minus
one byte between chunks, so a secret split across a chunk boundary is still
covered, and flushes at the end of the step. Runner-generated error text and vole workflow run’s output go through the same pass.
Documented as best effort: a value that is base64-encoded, hex-encoded, or embedded in a URL will not be masked. Masking guards against accidents; the approval gate is the control.
Audit and interface
Every delivery writes one line: secret.read, actor is the runner’s account,
subject is the repository, detail names the run, ref, and versions. Every human
read and every change is recorded the same way, and the settings page shows names,
version, who set it, when it was last read — never a value.
vole secret list <repo>
vole secret set <repo> <NAME> [--from-file <path>] # value on stdin otherwise
vole secret get <repo> <NAME> # value on stdout, audited
vole secret rm <repo> <NAME>
set never takes the value as an argument: argv is visible to every process on the
host and lands in shell history. list and get need maintain or above; the web
form saves and deletes only, and never renders a value.
What this promises, in plain words
Two statements belong in the docs, because both are true and a reader should not have to infer them:
- Approving a contributor’s branch tip grants that tip the repository’s secrets, along with the runner, the network and the release path it already grants.
- Whoever runs a runner for a repository can read every secret that repository’s jobs declare, because the values arrive on that machine. The runner allowlist is the control, not the container.
The other channels stay as they are and are worth repeating on the page: /cache
persists between jobs and is not a secret store, artifacts and site deploys are
published to the server, and an image-file build receives no secrets.
What this does not do
- No audience, protected-ref rule, or per-secret allowlist. Approving a tip is the trust boundary; a second one would be a rule to keep in sync with the first.
- No step-scoped secrets, expressions for building a secret name, required-secret declarations, rotation history, or instance-wide scope.
- No secret for an image build, an environment protection rule, or a service container. Those are #138 items that need their own design.
- No file or certificate delivery. A large value is a path the step fetches, not something the store carries.
Scope
Slice 1 — the store and the people side:
-
forge/secret: schema, seal and open, version,last_read, repository scope. -
secrets_keyin the configuration, the key file created at0600, both paths added toTightenSecretsinforge/instance.goand to the backup list indocs/operations.md. -
vole secret list,set,get,rm, withsetreading from stdin or a file andgetrequiring maintain or above. - A secrets section on
/r/{name}/settings: names, version, last read, and set/replace/delete. No value is ever rendered. - Audit records for read, write, and delete.
-
docs/architecture.mddata table, and the “keep the host key as the only secret” line in Why these choices, which stops being true.
Slice 2 — CI:
-
secrets:inci/workflow.go: name shape, duplicates rejected, pinned on the run row besideEnv, andcheckRecordcovering what was delivered. - Lease-time resolution in
server/ssh/runner.go, values in the reply and out of the queue. - The runner writes
secrets.envinto the run directory and prefixes each step exec with. /run/vole/secrets.env;vole workflow rundoes the same. - The absent-and-oversized warning line.
-
docs/workflows.mdanddocs/reference/workflow-reference.md: the field, the unset-not-empty rule, and the two limits.
Slice 3 — the safety net:
- Masking in
ci/agent/logstream.gowith the carry across chunks, and the same pass over runner error text and local run output. - The two trust statements in
docs/accounts-and-access.mdanddocs/runner.md, and the/run/voleparagraph there extended to cover the secrets file.
Acceptance criteria
- A job can read a declared secret as an environment variable, and the value
appears in no argv, no
runs.dbrow, and no stored log. - A job whose declared secret does not exist runs with the variable unset and one warning line in its log; it does not fail.
- A value longer than one environment string can carry is refused when it is stored, with a message that says why.
- A maintainer can set, read, and delete a value from the CLI and from the settings page; nobody can read one from a page.
- Every delivery and every human read appears in
data/audit.logwith the account, the repository, the name, and the version. - An attempt record names the secret versions it used.
-
docs/architecture.md,docs/operations.md,docs/runner.md,docs/workflows.md,docs/reference/workflow-reference.md,docs/accounts-and-access.md, anddocs/cli.mddescribe what was built.
Related
- #138
01M69AZZ9MHCY6K8BPYP5BDEDG— the parent: this closes part 4 and depends on the approval flow that #138 already records as the trust boundary. - #11 — the runner trust boundary this must not weaken.
- #17, #18 — the run record and log pages this extends.
- #64 — combined-result runs for change requests; a run queued by one is a run like any other, so it may read the secrets its workflow declares.