Secrets
Why this exists
The default way developers hold credentials is a .env file and an exported shell
variable, and both are worse than they look.
An exported variable is inherited by every process you launch afterwards, for as long
as that shell lives. Not just the command that needs it - the package manager, its
lifecycle scripts, your editor's language server, whatever a dependency decided to run at
install time. That is the shape of a supply-chain attack: the malicious postinstall does
not have to steal anything clever, it just reads its own environment. A .env file is the
same exposure with a longer half-life, sitting in plaintext on disk, surviving reboots,
occasionally surviving into a git history.
Neither is a mistake anyone makes out of carelessness. They are what the tooling made
easy, and the failure modes are the boring ones: a variable you forgot to unset, a file
you meant to delete, a .gitignore entry that was added one commit too late.
A secret provider changes the shape of the exposure, and it is worth being precise about how:
- The value exists for one read, in one process. It is fetched when a target asks for it and handed to the one subprocess that needs it, over stdin. It is not in your shell, so nothing you launch later inherits it.
- Nothing is stored in plaintext. The magusfile holds a reference. The backend holds the secret, behind whatever authentication it already enforces.
- The blast radius of a compromised machine shrinks to what an attacker can unlock, rather than everything you have ever exported or written to a file.
- Reaching for a credential is recorded. Every read lands in the invocation journal with its reference and provider, so "what did this run touch" has an answer.
This is what makes running integration tests against real infrastructure from a laptop a reasonable thing to do rather than a thing you get away with. The credential is scoped to the run that needs it.
magus does not stop a compromised machine, and nothing here claims to. What it removes is the standing exposure - the plaintext file and the inherited variable that are readable long before and long after the moment they were needed.
The built-in environment provider still reads environment variables, because a CI workflow's
env:block is the only thing that can reach a repository secret - there, the platform owns the secret store and the exposure is already scoped to one job. That provider is the CI bridge, not an endorsement of.envon a workstation. Locally, select a real backend.
Reading a credential
A magusfile can already read a credential. os\env("DOCKERHUB_TOKEN") works, and so
does shelling out to op read with os\exec. What neither
can do is tell magus that the value is sensitive.
That distinction is the whole feature. A value resolved through a secret provider is one magus recognizes on the way out, so it never reaches a run log, a terminal, or the output store in the clear.
final token = magus\secret.read("DOCKERHUB_TOKEN");
os\exec("docker", args: ["login", "docker.io", "-u", user, "--password-stdin"],
dir: ".", opts: {"stdin": token});
If the command prints that token back - and plenty of tools do, in a debug dump or a failure trace - magus redacts it:
DEBUG: authenticating to ghcr.io with password=***
Read the same variable with os\env and magus has no reason to protect the result. That
is the seam, and it is deliberate rather than a gap.
Providers
Where a secret comes from is a provider's job. magus ships one and treats everything else as a spell.
The built-in environment provider
With no provider selected, a reference is an environment variable name. This is what CI
needs, because a workflow's env: block is the only thing that can read a repository
secret - nothing outside the workflow file can:
- name: Log in to image registries
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: magus run image-login:cd
An unset or empty variable is an error naming the variable, not an empty string. A blank credential fails later at whatever consumes it, with an error far from the cause.
Spell-backed providers
Any other backend is an ordinary spell exposing one handler op, selected the same way a CI provider or a remote cache backend is:
import "spells/onepassword" as secrets;
magus\secret.provider(secrets);
final token = magus\secret.read("Private/DockerHub/token");
// spells/onepassword/spell.buzz
export fun mgs_getName() > str { return "onepassword"; }
export fun resolve_secret(target: Target, cb: fun(any)) > str {
var io = {};
cb(io);
return os\exec("op", args: ["read", "op://" + ("" + io["ref"])]).stdout;
}
A declared provider wins over the built-in, so selecting one is how a laptop avoids exporting tokens into a shell while CI keeps using the environment.
magus ships this one at spells/onepassword/, imported by path because a spell that
imports a host module cannot be compiled into the binary. Copy it as the starting point
for any backend with a CLI that prints a secret to stdout.
Setup, and being honest about it
This is where a secret provider either lands or dies. "Install a CLI, authenticate it, wire it up" is three steps more than exporting a variable, and if any of them fails with a bare exit code nobody comes back. So the whole path is two commands and every failure says what to type next:
mise use -g op # or: brew install 1password-cli
op signin
import "spells/onepassword" as secrets;
magus\secret.provider(secrets);
The spell classifies its own failures rather than surfacing an exit status:
| What went wrong | What magus tells you |
|---|---|
op not on PATH |
the mise, homebrew, and manual install commands |
| not authenticated | Run \op signin`, or set OP_SERVICE_ACCOUNT_TOKEN for an unattended run` |
| wrong vault/item/field | op item list --vault <vault> to find the right one |
| anything else | the CLI's own stderr, trimmed |
For CI or any unattended run, a 1Password service account token avoids the interactive signin entirely - but note that on a CI runner the platform's own secret store is usually the simpler answer, and the built-in provider already reads it.
There is deliberately no URI scheme. op://-style references exist in tools that
resolve providers per secret, with no selection step; magus selects once, explicitly, so
a reference does not also have to declare which backend it belongs to. The reference
format is the provider's own - a variable name here, a vault path there - and magus
passes it through without parsing.
One provider per run, and why there is no fallback chain
A realistic setup has different backends in different places: 1Password on a laptop, whatever CI can reach in CI, and a production vault a developer cannot read at all. The question that follows is whether a magusfile should name several providers and let magus try them in order.
It should not, and magus deliberately cannot. One provider is active per run, and a failed read is an error rather than a reason to try the next backend.
The reason is not simplicity. A fallback chain fails open: a locked 1Password vault silently falls through to a stale environment variable, the read succeeds, and the build pushes with the wrong credential. That failure is invisible - everything is green - and it is strictly worse than an error naming the reference and the provider. Secrets are the one place where "try harder to succeed" is the wrong instinct.
So provider selection is an environment decision, made once:
// CI reaches nothing but its own environment, and the built-in provider is already
// that, so CI selects nothing at all. A laptop opts into 1Password.
if (os\env("CI") == null) {
magus\secret.provider(onepassword);
}
Keeping one reference across backends
That leaves the real problem: the same credential is DOCKERHUB_TOKEN to the environment
provider and Private/Docker Hub/credential to 1Password. If the magusfile hard-codes
either, it is no longer portable.
The fix is that a reference is logical, and mapping it to a backend address is the provider's job:
// spells/onepassword/spell.buzz - a house convention, expressed in one place
export fun resolve_secret(target: Target, cb: fun(any)) > str {
var io = {};
cb(io);
final ref = "" + io["ref"]; // "dockerhub-token"
return os\exec("op", args: ["read", "op://Engineering/" + ref + "/credential"],
dir: ".", opts: {}).stdout;
}
Now the magusfile says magus\secret.read("dockerhub-token") and never learns which
backend served it. Each provider owns its own naming convention, one file per backend, and
the credential a developer cannot reach simply fails with a message naming what it wanted -
which is the correct outcome, not a gap.
This is the same principle as deriving a registry's auth realm: magus does not model the vendors, it gives you the place to express them.
Note: magus's own magusfile uses environment-variable names as references, because it publishes from CI and the built-in provider is the only one it needs. That is the less portable choice, taken knowingly. A workspace that expects several backends should use logical names from the start - retrofitting them means touching every call site.
When a single run genuinely needs two backends
It happens: a build that reads a CI token from the environment and a signing key from a vault. Handle it inside one provider spell that routes on a prefix it defines, rather than by asking magus for multiple active providers. Routing in a spell is explicit, testable, and visible in one file; multiple active providers pushes the same decision into the engine where nobody can see which one answered.
Resolution is lazy, announced, and bounded
Three rules, and each exists because the alternative is infuriating.
Nothing resolves until something needs it. A credential is fetched when a target
calls magus\secret.read, never at magusfile evaluation. This matters more than it
sounds: a magusfile that read a secret at the top level would prompt on magus ls, on
magus describe, on every command in the workspace. Keep reads inside target bodies, and
keep the act of authenticating in its own target rather than as a side effect of a
build - see the convention below.
The same rule applies to reporting. magus run image-registries lists what a publish
needs and resolves nothing; only image-registries:cd,verify - a user explicitly asking
"am I set up" - actually calls the provider. A status table that pops an unlock dialog is
the single most annoying thing this feature could do.
Every wait is announced before it happens. A provider that prompts prints first:
secret: waiting on onepassword for "Private/Docker Hub/credential" (timeout 90s)
An unexplained biometric prompt in the middle of a build is a trust failure, not a UX wrinkle - you cannot tell whether magus asked for it or something else on your machine did. The line names what is waiting, what it wants, and how long it will wait. The journal records the read afterwards; this is the half you can see while the dialog is on screen.
No terminal means fail fast, not wait. With a TTY, magus allows 90 seconds for you to answer an unlock. Without one, it allows 10 - because a provider that would prompt cannot, so it either answers from a cached session immediately or it is going to block until something else kills it. The error says which situation you are in:
provider "onepassword" needed 10s and there is no terminal to prompt on;
configure an unattended credential for it (a service-account token) or run this interactively
Both budgets are configurable, per workspace:
# magus.yaml
secret:
timeout: 60s # waiting for a person to complete an unlock
unattended_timeout: 10s # waiting for a machine, with no terminal to prompt on
Each also has a MAGUS_SECRET_TIMEOUT / MAGUS_SECRET_UNATTENDED_TIMEOUT environment
variable and a --secret-timeout / --secret-unattended-timeout flag. Raise the
unattended one if a service-account backend is genuinely slow; raising it to hide a
missing credential just moves the failure later.
That is deliberately not phrased as a timeout. "Timed out" invites a retry; "there is nobody to ask" tells you to wire a service account. In CI the failure arrives in seconds rather than at the job's 45-minute limit.
Why magus will not let you paste a secret instead
Buzz can read stdin, so magus could offer "provider unavailable - paste the value to continue" during a wait. It deliberately does not, for four reasons that compound:
- It undoes the point. The value ends up in your terminal buffer, your scrollback, your multiplexer's history, and any screen recording. That is the standing exposure this feature exists to remove.
- It builds a phishing surface. Once magus is a thing that asks for credentials at a prompt, any magusfile can ask for credentials at a prompt that looks exactly like magus's. The announcement above is meant to make an unexplained request suspicious - a paste prompt makes it routine.
- It has no provenance. A pasted value came from no provider, so the audit trail cannot say which backend served it, and "which credentials did this run touch" loses its answer.
- It is a fallback, and fallbacks are already ruled out above: one that succeeds with the wrong value is worse than a failure, because everything stays green.
The real need behind the question ("my vault is locked and I just want this build to run") has a better answer that already works, is explicit, and leaves no prompt surface:
GHCR_TOKEN=... magus run image-login:cd
Scoped to one invocation, no provider selected so the built-in environment provider serves it, and nothing persists after the process exits.
The -login convention
Give authentication its own target, named <area>-login:
magus run image-login:cd # authenticates, and does nothing else
magus run image-build:cd # builds and pushes, assuming you already did
Every property above depends on this split. A build target that authenticates as a side effect cannot be lazy, cannot be run without credentials, and gives no honest place to announce a wait. Separating them means the expensive, interactive, privileged step is one you asked for by name.
Handing a secret to a container build
--build-arg bakes its value into image history, where anyone who pulls the image can
read it. Use a BuildKit secret instead: magus resolves the credential, passes it to buildx
through the environment, and the Dockerfile mounts it at a path that never enters a layer.
export fun build(ctx: magus\Context, args: [str]) > void {
final token = magus\secret.read("Private/Registry/token");
docker["docker-buildx"](ctx.withEnv({"BK_TOKEN": token}), {"args": [
"--secret", "id=registry_token,env=BK_TOKEN",
"-t", "demo:latest", ".",
]});
}
RUN --mount=type=secret,id=registry_token \
TOKEN="$(cat /run/secrets/registry_token)" && ./fetch-private-dep.sh
The argv magus runs carries only --secret id=registry_token,env=BK_TOKEN - the flag, not
the value - so nothing sensitive reaches the run log. The child process does receive the
real value, because BuildKit needs it, and if the build echoes it back magus redacts it on
the way out.
What magus does with a resolved value
- Redacts it from captured output. Four paths carry a subprocess's bytes and all
four are covered: the live stream (terminal), the raw run log, the buffered result a
magusfile reads back from
os\exec, and the command line recorded in the invocation journal. They are genuinely separate - the buffered result bypasses the live tap entirely, and a quiet capture has no live tap at all - so each is redacted at its own point rather than at one shared choke point. The mask is a fixed***, so it does not leak the value's length. - Records the read in the invocation journal. A
secretevent carries the reference and the provider that served it - never the value - so an audit can answer which credentials a run reached for and through which backend. - Memoizes it per reference. A provider that shells out is usually invoked once per reference rather than once per call site. Usually, not always: two targets resolving the same reference at the same moment can both miss the memo and both invoke the provider, which for an interactive backend means two unlock prompts.
Why a secret is a str and not its own type
A reasonable instinct is that magus\secret.read should return a distinct Secret type
so a credential cannot be mistaken for an ordinary string. It does not, and the reason is
that the type would not be enforced where it matters.
Buzz checks function signatures - fun registries(ctx) > [Registry] is a real
constraint, and a wrong return type fails the build. It does not check host call
results: every module magus exposes (os, fs, magus, ...) is typed as unknown to
the checker, so a Secret coming back from magus\secret.read would be unknown too.
Every call site would gain a .value() unwrap and the checker would verify none of it.
What protects a secret is not its type, it is its provenance: magus knows the value is a credential because it was resolved through the resolver, and that knowledge survives being assigned, concatenated, and passed to a subprocess - all the things that discard a type. Redaction keys off having been read, so a wrapper adds ceremony without adding protection.
Where types DO earn their place is in your own declarations. Keep the registry table an
object with named fields and keep the helpers' signatures honest:
fun publish_registries(ctx: magus\Context) > [Registry] { ... }
That signature is checked. Registry fields carrying secret references rather than
values is a convention this page recommends, not a guarantee the compiler makes.
Limits
These are real, and stating them matters more than the guarantees do: a partial guarantee described as total changes what people are willing to risk.
- Only resolved values are known. A credential a magusfile read with
os\env, bypassing the provider, is invisible to redaction. - It is literal substring replacement. A process that base64-encodes, URL-escapes or splits the value defeats it.
- It cannot see across a write boundary. A secret straddling two separate writes from a child process is redacted only if both halves land in one write.
- Very short values are not redacted at all, and you are not told. Below four characters, masking every occurrence would shred ordinary output while protecting something that was never a credential - so magus declines. The internal report for it is not currently wired to anything, so a credential this short is silently unprotected. Do not rely on a warning.
- magus cannot stop a process from doing what it likes with a value you gave it. Redaction covers what magus captures, not what a tool writes to a file of its own.
Prefer keeping a secret out of an argument list in the first place. magus captures a
command's argv into the run log and the output store, so --password-stdin with
opts.stdin beats -p <token> even with redaction in place.
See also
- Output references - the durable store redaction protects
- Tips and tricks - the declare-once registry-table pattern
- Writing a spell - the full provider contract
- CI integration