---
title: "Credentials: an API key the machine uses and never holds"
description: "Let a workload use an API key it can never read."
canonical: "https://smolmachines.com/docs/guides/skills/credentials"
source: "https://github.com/smol-machines/docs/blob/main/guides/skills/credentials.md"
---

# Credentials: an API key the machine uses and never holds

Gives a workload in a smolvm machine an API key or token it can use but never read, by binding the credential to named HTTPS hosts so the guest holds only a placeholder and the host substitutes the value on the way out, then proves on the host that the value never entered the machine. Use when an agent or untrusted code inside a machine has to call an API with your key; when deciding between a credential binding and --secret-env; when a request from a credentialed machine comes back 403 or 502 from smolvm itself; or when you need evidence that a key stayed out of a machine. Do not use it for a value the program must read and parse, such as a database URL, which is --secret-env, or for git and ssh keys, which is SSH agent forwarding.

Verified on **smolvm v1.29.0** on macOS arm64, 2026-10-15, and on **v1.18.2** on Linux aarch64, 2026-09-24, for everything that
is decided on the host; on v1.29.0 on macOS arm64 **the substituted value also arrived at a real API**
that echoes the headers it received, a dummy value. Done means the guest's variable is a placeholder, the value
is nowhere in the guest, the machine's record or smolvm's database, TLS to the bound host goes
through the machine's own CA, and a placeholder anywhere but a request header is refused before it
leaves the host (from v1.27.0 a binding with `:body` also substitutes into a form body).
The Linux runs used the scripts of their date; this version's preflight and cleanup scripts ran
on Linux aarch64 on v1.22.2 on 2026-10-03.

`docs/credential-substitution.md` in the smolvm repository is how the feature works: bindings,
where the value comes from, what the guest sees, what happens to each request, and the limits.
`README.md` has what was measured beside it. This file is how to set it up and prove it.

## Procedure

```
- [ ] 1. preflight.sh             the flag, the network backend, the host variable
- [ ] 2. create-credentialed.sh   a machine bound to one host
- [ ] 3. verify-containment.sh    the value stays out; the interceptor is on the path
- [ ] 4. your own request         the first call that sends the value
- [ ] 5. cleanup.sh
```

**1. Preflight.** Read-only, and it never prints the value or its length.

```bash
export API_TOKEN=...          # however your secrets manager hands it over
scripts/preflight.sh --var API_TOKEN --host api.example.com
```

Every script here reads the value from its own environment, so keep it exported in the shell that
runs `create-credentialed.sh` and `verify-containment.sh` as well as this one.

```
has_credential_flag=yes
default_backend=virtio-net
tsi_stream_intercept=yes
host_var_set=yes
host_form=ok
memory_required_mib=1024
result=ready
```

`tsi_stream_intercept` is read from the symbols of the bundled libkrun: a binding selects virtio-net
by default, and `--net-backend tsi` carries a credential only when libkrun exports
`krun_set_stream_intercept`, which both v1.18.2 release libraries do. `host_form=rejected` means the
host is neither a lowercase DNS name nor, from v1.26.0, a wildcard such as `*.example.com`; `create`
would refuse it anyway, with a clearer message.

**2. Create the machine.** The value is read from the environment of this command's `machine
start`, and that is the value the machine uses until its next start.

```bash
scripts/create-credentialed.sh --var API_TOKEN --host api.example.com
```

```
workload_ready_after_s=1
guest_sees=placeholder
binding=api-token host=api.example.com
result=up
```

The command it runs is `smolvm machine create ... --credential api-token=API_TOKEN@api.example.com`
and then `machine start`. `--credential` implies `--net`. In a Smolfile the same binding is a
`[[network.credentials]]` table, in `docs/credential-substitution.md`.

**3. Prove the value stayed out.** Nothing this sends carries the placeholder where it would be
substituted, so the value goes nowhere during the check. On an Alpine guest it adds openssl and
curl with apk, which step 4 then uses. Run it on the machine `create-credentialed.sh` made, before
any workload of yours runs in it: the guest search hands the value to a `grep` in the guest for the
length of the search, so a workload that can replace `grep` could read it.

```bash
scripts/verify-containment.sh --var API_TOKEN --host api.example.com --other-host example.org
```

```
guest_variable=ok (placeholder)
value_in_guest=ok (0)
value_in_record=ok (0)
interception=ok (on)
passthrough=ok (real)
query_refused=ok (403)
refusal_text=smolvm credentials: placeholders are substituted in request headers only
result=contained
```

`value_in_guest` hands the value to a `grep` inside the guest on standard input for the length of
the search, and looks in every process environment and the writable trees inside the guest;
`value_in_record` searches the machine's directory and smolvm's database on the host;
`interception` reads the issuer of the bound host's certificate as the guest sees it, which is
`smolvm <machine> credential CA`, while `--other-host` keeps its real issuer.

**4. Use it.** The workload sends the placeholder in a header, exactly where the real key would go.
`smolskill-cred` is the machine `create-credentialed.sh` makes unless you pass `--name`:

```bash
smolvm machine exec --name smolskill-cred -- sh -c \
  'curl -sS -H "Authorization: Bearer $API_TOKEN" https://api.example.com/v1/me'
```

That is the first request that carries the value, and it goes only to the host you bound. Judge it
by the API's answer, the way you would without smolvm.

**5. Clean up.**

```bash
scripts/cleanup.sh --purge
```

`cleanup.sh` waits up to 20 seconds, polling the machine list, and prints `waiting=up to 20s`
first: an ephemeral machine's entry retires after its run returns.

## When the workload has to read the value

A binding keeps the value out of the guest, which is what this packet is for. When the workload
itself has to hold the key, `--secret-env` puts it in the workload's environment instead, resolved
from a host variable when the machine launches:

```bash
smolvm machine run --image alpine --secret-env API_TOKEN=API_TOKEN -- sh -c 'test -n "$API_TOKEN" && echo set'
```

On v1.23.1 on macOS arm64, with a dummy value in `API_TOKEN`, that printed `set`. With the host
variable unset the run stopped before the workload with `Error: config operation failed: resolve
secret 'API_TOKEN': env_unset`. The value is plaintext in the guest, where any process can read it
and send it anywhere the network allows; that is why the binding is the default here.

## Traps

Full detail in `references/traps.md`.

- **An environment value behaves as the one the machine started with**; `README.md` has the
  v1.18.2 measurement. Rotate it with a restart, or use a file reference,
  read per request.
- **Never put the real value on an `exec` command line.** smolvm writes exec commands into the
  machine's console log on the host, so a check that interpolated the value into its own `grep`
  planted it in `agent-console.log` and failed itself. `verify-containment.sh` hands the value to
  `grep` on standard input, in the guest and on the host, for that reason.
- **A 403 from smolvm is a refusal, not the API.** The body says which rule: a placeholder in the
  path or query, or in a body without `:body`; in a routing or framing header such as `Cookie`;
  more than one in a request; or one the machine did not mint. A `403` with `this credential is not allowed for this host`
  means the placeholder belongs to a binding for another host; a `405` means the binding does not
  allow the method.
- **On v1.18.2 a machine created from a pack ignores `--credential`**, silently; on v1.22.2 it
  binds. `references/traps.md` has both.
- **Port 80 is not intercepted.** Plaintext HTTP is relayed untouched, so a placeholder there
  travels as the literal string and the API sees garbage.
- **The machine's CA is the only one the guest trusts for the bound host.** curl, Python, Node,
  Deno and Git pick it up from the variables smolvm sets; a client with its own trust store needs
  `/run/smol/credentials/ca.pem` added.

## Security defaults, and why they are the defaults

- **A binding is narrower than a secret.** `--secret-env` puts the plaintext in the workload's
  environment, where any code in the guest can read and send it anywhere the network allows. A
  binding lets the workload use the key only in a header, or a form body when you add `:body`, only
  toward the hosts named.
- **Bind to the narrowest hosts that work.** A host is an exact name or, from v1.26.0, a whole-label
  wildcard such as `*.example.com`, which matches its subdomains and not `example.com` itself; a
  one-label parent such as `*.com` is refused. When the machine also has `--allow-host`, every
  credential host has to sit inside that list, and a wildcard only when the list admits its whole
  subtree; a create that breaks either rule is refused.
- **Substitution is not data-loss prevention.** An API that echoes your key back in a response
  body hands it to the guest. Bind keys only to APIs you trust with them.
- **The scripts never print the value** or its length, and the preflight reports only whether the
  variable is set.
- **Cleanup deletes only machines the scripts recorded** under the `smolskill-` prefix.

## Platform arms

`references/platforms.md`. macOS arm64 ran every script on v1.29.0 and v1.22.2, and both hosts gave the same
results on v1.18.2, on the default virtio-net backend and on `--net-backend tsi`. Linux x86_64 and
Windows were not run.

## Eval prompts, and what they produced

Run 2026-09-24 PT against v1.18.2 from the published release, under an isolated `HOME`, on macOS
26.6.2 arm64 and Lima `linux-kvm` (Ubuntu 24.04 aarch64), with a random throwaway value and
`example.com` as the bound host.

**1. "Let the agent in this machine call the API with my token without the machine ever having the
token."**

`preflight.sh`, `create-credentialed.sh` and `verify-containment.sh` in order, both hosts:
`result=ready`, `guest_sees=placeholder`, then `result=contained` with all six checks as shown
above. The guest's variable read `SMOL_PLACEHOLDER_DEMO_E0226293E472287B661DFAB687C5DE0C` on macOS.

**2. "Every request from my machine to the API now comes back 502 from smolvm."**

The machine was started without the variable in its environment. Reproduced on both hosts by
stopping a working machine and starting it again with the variable unset:

```
smolvm credentials: credential unavailable [502]
```

and by contrast, unsetting it only for `machine exec` on a machine started with it did not produce
the 502. Start the machine with the variable set.

**3. "Prove the key cannot be sent anywhere but the API I named."**

`verify-containment.sh` shows the bound host's certificate is the machine's own CA and another
host's is its real one, and these refusals were measured on both hosts with the placeholder, none
of which forwarded anything:

```
?k=<placeholder> in the query            -> 403 placeholders are substituted in request headers only
-d k=<placeholder> in the body           -> 403 placeholders are substituted in request headers only
two headers carrying the placeholder      -> 403 a request may carry one placeholder
a forged SMOL_PLACEHOLDER_DEMO_...        -> 403 unknown placeholder
Cookie: a=<placeholder>                   -> 403 placeholders are not substituted in routing or framing headers
```

From v1.27.0 the body case reads `403 smolvm credentials: body substitution is disabled for this
binding`, and a binding created with `:body` substitutes into an `application/x-www-form-urlencoded`
body instead; that is read from the source, not run here.

Create-time rules, both hosts, on v1.22.2: `*.example.com` and an IP address are refused with
`must be an exact lowercase DNS name`, and a credential host outside the machine's `--allow-host`
with `is not reachable under the machine's network allow_hosts`. From v1.26.0 `*.example.com` is
accepted, and an IP address is refused with `must be a lowercase DNS name, optionally` followed by
the wildcard form.

## Re-verified on v1.29.0

Run 2026-10-15 PT against v1.29.0 from the published release, checksum checked, under a fresh
isolated `HOME` on macOS 27.0.1 arm64, the scripts twice, the second time from a fresh `HOME`, and
the checks beyond them once. Linux was not run, so the Linux stamp stays on its earlier release.

The preflight said `result=ready` with `tsi_stream_intercept=yes` once `SMOLVM_LIB_DIR` was set,
`create-credentialed.sh` `guest_sees=placeholder`, and `verify-containment.sh` gave every line `ok`
and `result=contained`. Bound to `httpbin.org`, the step 4 request to `https://httpbin.org/headers`
came back with `Authorization: Bearer <the value>`, a dummy value made for the run, so the value
reached the API. `--secret-env` printed `set`, and with the host variable unset the run stopped with
`resolve secret 'API_TOKEN': env_unset`.

## Re-verified on v1.22.2

Run 2026-10-03 PT against v1.22.2 from the published release, checksum checked, under an isolated
`HOME` on macOS 27.0.1 arm64, twice, the second time from a fresh `HOME`. Apart from `preflight.sh`
and `cleanup.sh`, this packet was not run on v1.22.2 on Lima `linux-kvm` (Ubuntu 24.04 aarch64),
where guests above 2048 MiB timed out on 2026-10-03.

macOS, bound to a local HTTPS server with a throwaway value: `result=contained` on all six
checks, the `Cookie` and forged placeholders refused with `403`, and the request carrying the
placeholder answered `502 smolvm credentials: upstream request failed`, because the server's
certificate was self-signed; the server received nothing. With the disk images left out of the
record search, `verify-containment.sh` and the rest of the procedure took 25 s.

## What was not run

- **The value arriving at an API, before v1.29.0.** Seeing it needs an HTTPS endpoint with a
  publicly trusted certificate that reports the header it received; on v1.29.0 on macOS arm64 one
  did ("Re-verified on v1.29.0"), and it was not observed on Linux. On v1.18.2 on macOS arm64 a
  substituted request to `example.com` was forwarded and answered `200`, which `example.com` gives
  whatever the header holds; on v1.22.2 on macOS arm64 the substituted request to a local server
  with a self-signed certificate was answered `502 smolvm credentials: upstream request failed` and
  reached nothing. Every check on the host side ran on macOS arm64 on both releases and on Linux
  aarch64 on v1.18.2.
- **File references and rotation in place**, which `docs/credential-substitution.md` describes;
  nothing here changed a value under a running machine except by the variable.
- **Credentials over the HTTP API**, branches and checkpoints carrying bindings, and portable
  restores on another host.
- **Linux x86_64 and Windows.**

## Related packets

- `throwaway-machine` for `--allow-host`, which a binding must fit. Give a machine a binding with
  the commands in this packet, which are the ones verified with it.
- `local-api` for the `credentials` field on a create body.
- `teardown` for the wider cleanup.

## Scripts

The files the procedure runs, in the order it runs them. It calls each one by the path in its heading, relative to the directory the procedure is saved in.

### `scripts/preflight.sh`

```bash
#!/usr/bin/env bash
# Report whether this host can give a machine a credential it can use but not
# read: the flag, the network backend the interceptor needs, and whether the host
# variable that holds the value is set. Read-only: starts no VM, writes no smolvm
# state, and never prints the value.
#
# usage: preflight.sh --var <HOST_ENV_VAR> --host <api.example.com>
#
# Output is one key=value per line so a caller can parse it. The last line is
# always result=ready or result=blocked.

set -uo pipefail

VERIFIED_VERSION="1.29.0"

emit() { printf '%s=%s\n' "$1" "$2"; }

blocked=0
note() { printf 'note=%s\n' "$1"; }

VAR=""
HOST=""
while [ $# -gt 0 ]; do
    case "$1" in
        --var)  VAR="$2"; shift ;;
        --host) HOST="$2"; shift ;;
        *) printf 'unknown argument: %s\n' "$1" >&2; exit 2 ;;
    esac
    shift
done

# --- the binary --------------------------------------------------------------

SMOLVM="${SMOLVM:-$(command -v smolvm 2>/dev/null)}"
if [ -z "$SMOLVM" ]; then
    emit smolvm_installed no
    emit smolvm_version ""
    blocked=1
else
    emit smolvm_installed yes
    version="$("$SMOLVM" --version 2>/dev/null | awk '{print $NF}')"
    emit smolvm_version "${version:-unknown}"
fi

emit verified_version "$VERIFIED_VERSION"
if [ -n "${version:-}" ] && [ "$version" != "unknown" ]; then
    if [ "$version" = "$VERIFIED_VERSION" ]; then
        emit version_status match
    else
        newest="$(printf '%s\n%s\n' "$version" "$VERIFIED_VERSION" | sort -V | tail -1)"
        if [ "$newest" = "$version" ]; then
            emit version_status newer
            note "this packet was verified on $VERIFIED_VERSION and the binary is $version; flags and messages move every release, so check the output against the binary before trusting a step here"
        else
            emit version_status older
            note "this packet was verified on $VERIFIED_VERSION and the binary is $version"
        fi
    fi
else
    emit version_status unknown
fi

# --- platform ----------------------------------------------------------------

kernel="$(uname -s)"
arch="$(uname -m)"
case "$arch" in aarch64|arm64) arch=aarch64 ;; esac

case "$kernel" in
    Darwin)
        emit platform "darwin-$arch"
        emit accel hvf
        emit macos_version "$(sw_vers -productVersion)"
        hv="$(sysctl -n kern.hv_support 2>/dev/null)"
        if [ "$hv" = "1" ]; then emit accel_access ok; else emit accel_access denied; blocked=1; fi
        if [ "$arch" != "aarch64" ]; then
            emit hardware_verified no
            note "Intel Mac is not verified by this packet; the installer accepts it and nothing here was run on one"
        else
            emit hardware_verified yes
        fi
        # A VM's agent socket lives under the cache directory. macOS sockaddr_un
        # holds 104 bytes including the terminator.
        sock="$HOME/Library/Caches/smolvm/vms/0123456789abcdef/agent.sock"
        len=${#sock}
        emit socket_path_bytes "$len"
        if [ "$len" -gt 100 ]; then
            emit socket_path_status too_long
            blocked=1
            note "HOME is too deep: every VM start will fail. From v1.28.0 the error names the socket path and its length; before it, krun_start_enter -22, whose text blames disks and device options. Install under a shorter HOME."
        else
            emit socket_path_status ok
        fi
        ;;
    Linux)
        emit platform "linux-$arch"
        emit accel kvm
        emit socket_path_status n_a
        if [ ! -e /dev/kvm ]; then
            emit accel_access missing
            blocked=1
            note "/dev/kvm does not exist; this host has no KVM"
        elif [ -r /dev/kvm ] && [ -w /dev/kvm ]; then
            emit accel_access ok
        else
            emit accel_access denied
            blocked=1
            note "your user cannot open /dev/kvm. The installer warns and continues, so a successful install says nothing about whether a VM will start. Fix: sudo usermod -aG kvm \$USER, then run the next command through sg kvm -c '...' rather than logging out."
        fi
        ;;
    *)
        emit platform "unsupported-$kernel"
        emit accel unknown
        emit accel_access unknown
        blocked=1
        note "this script covers macOS and Linux; Windows was not run for this packet."
        ;;
esac

# --- the feature ---------------------------------------------------------------

if [ -n "$SMOLVM" ]; then
    createhelp="$("$SMOLVM" machine create --help 2>&1)"
    # v1.27.0 prints the value as <NAME=ENV_VAR@HOST[:body]>, so match the prefix.
    if grep -q -- '--credential <NAME=ENV_VAR@HOST' <<<"$createhelp"; then
        emit has_credential_flag yes
    else
        emit has_credential_flag no
        blocked=1
        note "this smolvm has no --credential; credential substitution arrived in v1.18.0"
    fi
fi

# The interceptor sits on the machine's network path. A credential binding
# selects the virtio-net backend by default, which needs nothing more. TSI works
# only when libkrun exports krun_set_stream_intercept; otherwise a machine asked
# for --net-backend tsi fails at start. Read the bundled library's symbols
# rather than starting a VM to find out.
emit default_backend virtio-net
libdir="${SMOLVM_LIB_DIR:-$HOME/.smolvm/lib}"
lib="$(ls "$libdir"/libkrun.dylib "$libdir"/libkrun.so 2>/dev/null | head -1)"
if [ -z "$lib" ]; then
    emit tsi_stream_intercept unknown
    note "no libkrun found under $libdir; set SMOLVM_LIB_DIR to check whether --net-backend tsi can carry a credential"
elif command -v nm >/dev/null 2>&1; then
    case "$kernel" in Darwin) syms="$(nm -gU "$lib" 2>/dev/null)" ;; *) syms="$(nm -D --defined-only "$lib" 2>/dev/null)" ;; esac
    if grep -q 'krun_set_stream_intercept' <<<"$syms"; then emit tsi_stream_intercept yes; else
        emit tsi_stream_intercept no
        note "this libkrun cannot intercept TSI streams: leave the backend at its default, virtio-net, or a start with --net-backend tsi fails"
    fi
else
    emit tsi_stream_intercept unknown
fi

# The value itself: set or not, never shown. Its length is not shown either.
if [ -n "$VAR" ]; then
    if [ -n "$(printenv "$VAR" 2>/dev/null)" ]; then emit host_var_set yes; else
        emit host_var_set no
        blocked=1
        note "$VAR is not set in this environment. The value is read from the environment of the machine start, so set it there; a machine started without it answers every substituted request with 502 credential unavailable."
    fi
fi
if [ -n "$HOST" ]; then
    # From v1.26.0 a host may also be `*.` plus a name of at least two labels.
    name="${HOST#\*.}"
    case "$name" in
        *[!a-z0-9.-]*|*..*|.*|*.|[0-9]*[0-9]) form=rejected ;;
        *) form=ok ;;
    esac
    case "$HOST:$name" in \*.*:*.*|[!*]*) ;; *) form=rejected ;; esac
    emit host_form "$form"
    if [ "$form" = rejected ]; then
        blocked=1
        note "a credential host must be a lowercase DNS name, or *. plus a name with at least two labels: no scheme, port or IP address"
    fi
fi

emit memory_required_mib 1024

if [ "$blocked" -eq 0 ]; then emit result ready; else emit result blocked; fi
```

### `scripts/create-credentialed.sh`

```bash
#!/usr/bin/env bash
# Create and start a machine whose workload can use a credential it never holds.
#
# usage: create-credentialed.sh --var <HOST_ENV_VAR> --host <api.example.com>
#                               [--name <smolskill-...>] [--binding <name>] [--image <img>]
#   --var      the host environment variable holding the value; the guest gets a
#              placeholder under the same name
#   --host     the one host the value may be sent to; repeat the binding by hand
#              for more (NAME=VAR@host1,host2)
#
# The value is read from THIS process's environment when the machine starts, and
# the interceptor keeps using that one: unsetting or changing the variable later
# changes nothing until the next start. Set it in the environment of this script.

set -uo pipefail

here="$(cd "$(dirname "$0")" && pwd)"
VAR=""
HOST=""
NAME="smolskill-cred"
BINDING=""
IMAGE="alpine"
while [ $# -gt 0 ]; do
    case "$1" in
        --var)     VAR="$2"; shift ;;
        --host)    HOST="$2"; shift ;;
        --name)    NAME="$2"; shift ;;
        --binding) BINDING="$2"; shift ;;
        --image)   IMAGE="$2"; shift ;;
        *) printf 'unknown argument: %s\n' "$1" >&2; exit 2 ;;
    esac
    shift
done
if [ -z "$VAR" ] || [ -z "$HOST" ]; then
    printf 'usage: create-credentialed.sh --var <HOST_ENV_VAR> --host <api.example.com> [--name <n>]\n' >&2
    exit 2
fi
case "$NAME" in smolskill-*) ;; *) printf 'name must start with smolskill- so cleanup.sh will delete it\n' >&2; exit 2 ;; esac
[ -n "$BINDING" ] || BINDING="$(printf '%s' "$VAR" | tr 'A-Z_' 'a-z-')"
if [ -z "$(printenv "$VAR" 2>/dev/null)" ]; then
    printf 'result=FAILED %s is not set here, so every substituted request would answer 502 credential unavailable\n' "$VAR"
    exit 1
fi

SMOLVM="${SMOLVM:-$(command -v smolvm 2>/dev/null)}"
[ -n "$SMOLVM" ] || { printf 'smolvm not found; set SMOLVM to its path\n' >&2; exit 2; }

# A long-lived workload, so exec has a container to run in.
if ! "$SMOLVM" machine create --name "$NAME" --mem 1024 --image "$IMAGE" \
    --credential "$BINDING=$VAR@$HOST" -- sh -c 'while true; do sleep 3600; done' 2>&1 | sed 's/^/  /'; then
    printf 'result=FAILED the machine was not created; the lines above say why\n'
    exit 1
fi
"$here/cleanup.sh" --record "$NAME"
"$SMOLVM" machine start --name "$NAME" 2>&1 | sed 's/^/  /'

for i in $(seq 1 60); do
    seen="$("$SMOLVM" machine exec --name "$NAME" -- sh -c "printenv $VAR" 2>/dev/null)"
    case "$seen" in
        SMOL_PLACEHOLDER_*)
            printf 'workload_ready_after_s=%s\n' "$i"
            printf 'guest_sees=placeholder\n'
            printf 'binding=%s host=%s\n' "$BINDING" "$HOST"
            printf 'result=up\n'
            exit 0 ;;
        "") ;;
        *)
            printf 'guest_sees=NOT_A_PLACEHOLDER\n'
            printf 'result=FAILED the guest variable is not a placeholder; stop and do not use this machine\n'
            exit 1 ;;
    esac
    sleep 1
done
printf 'result=FAILED the workload never answered\n'
exit 1
```

### `scripts/verify-containment.sh`

```bash
#!/usr/bin/env bash
# Prove, on this host, that a credentialed machine never holds the value. Run it
# before any untrusted workload runs in the machine: check 2 hands the value to a
# grep inside the guest. Every check here is decided before anything leaves the
# host: no request this script makes carries the placeholder where it would be
# substituted, so the value is never sent anywhere. Seeing the value arrive at
# the API is your own first real call, which this script does not make.
#
# usage: verify-containment.sh --var <HOST_ENV_VAR> --host <api.example.com>
#                              [--name <n>] [--other-host <host>]
#   --other-host  a host no binding names, to show its TLS is passed through
#
# The value is never put on a command line: a command passed to machine exec is
# written to the machine's console log on the host. Both searches read it on
# standard input instead.

set -uo pipefail

VAR=""
HOST=""
NAME="smolskill-cred"
OTHER=""
while [ $# -gt 0 ]; do
    case "$1" in
        --var)        VAR="$2"; shift ;;
        --host)       HOST="$2"; shift ;;
        --name)       NAME="$2"; shift ;;
        --other-host) OTHER="$2"; shift ;;
        *) printf 'unknown argument: %s\n' "$1" >&2; exit 2 ;;
    esac
    shift
done
if [ -z "$VAR" ] || [ -z "$HOST" ]; then
    printf 'usage: verify-containment.sh --var <HOST_ENV_VAR> --host <api.example.com> [--name <n>]\n' >&2
    exit 2
fi
SMOLVM="${SMOLVM:-$(command -v smolvm 2>/dev/null)}"
[ -n "$SMOLVM" ] || { printf 'smolvm not found; set SMOLVM to its path\n' >&2; exit 2; }
value="$(printenv "$VAR" 2>/dev/null)"
[ -n "$value" ] || { printf 'result=FAILED %s is not set here, so there is nothing to look for\n' "$VAR"; exit 2; }

fail=0
check() {
    if [ "$2" = "$3" ]; then printf '%s=ok (%s)\n' "$1" "$2"; else printf '%s=FAIL expected=%s actual=%s\n' "$1" "$3" "$2"; fail=1; fi
}
X() { "$SMOLVM" machine exec --name "$NAME" -- "$@" 2>/dev/null; }

# 1. The guest's variable is a placeholder.
seen="$(X sh -c "printenv $VAR")"
case "$seen" in SMOL_PLACEHOLDER_*) check guest_variable placeholder placeholder ;; *) check guest_variable other placeholder ;; esac
[ "$fail" -eq 0 ] || { printf 'result=FAILED the guest variable is not a placeholder, so nothing more was checked or sent\n'; exit 1; }

X sh -c 'command -v openssl >/dev/null && command -v curl >/dev/null || apk add -q openssl curl >/dev/null 2>&1; command -v openssl >/dev/null && command -v curl >/dev/null' >/dev/null \
    || { printf 'result=FAILED the guest has no openssl or curl and apk could not add them\n'; exit 2; }

# 2. The value is nowhere in the guest: every environment, and the writable trees.
# A dropped standard input would read as zero hits, so prove it arrives first.
echo_back="$(printf 'smolskill-stdin-probe\n' | "$SMOLVM" machine exec -i --name "$NAME" -- cat 2>/dev/null)"
[ "$echo_back" = smolskill-stdin-probe ] || { printf 'result=FAILED machine exec -i did not pass standard input through, so the guest search could not read the value\n'; exit 2; }
hits="$(printf '%s\n' "$value" | "$SMOLVM" machine exec -i --name "$NAME" -- sh -c 'grep -rlF -f /dev/stdin /proc/[0-9]*/environ /run /etc /root /tmp /var /home 2>/dev/null | grep -v "^/proc/$$/" | wc -l | tr -d " "' 2>/dev/null)"
check value_in_guest "${hits:-unknown}" 0

# 3. Nor in the machine's directory or smolvm's database on the host.
dir="$("$SMOLVM" machine data-dir --name "$NAME" 2>/dev/null)"
case "$(uname -s)" in
    Darwin) db="$HOME/Library/Application Support/smolvm" ;;
    *) db="${SMOLVM_DATA_DIR:+$SMOLVM_DATA_DIR/.local/share/smolvm}"
       db="${db:-${XDG_DATA_HOME:-$HOME/.local/share}/smolvm}" ;;
esac
# The *.raw disk images are the guest filesystem step 2 searched from inside, and
# grep took about 19 minutes over their 30 GiB of sparse space on macOS.
if [ -d "$dir" ] && [ -d "$db" ]; then
    rec="$( { printf '%s\n' "$value" | grep -rlF --exclude='*.raw' -f /dev/stdin "$dir" "$db" 2>/dev/null || true; } | wc -l | tr -d ' ')"
    check value_in_record "$rec" 0
else
    check value_in_record "not_searched (${dir:-no machine directory}, $db)" 0
fi

# 4. TLS to the bound host is terminated by the machine's own CA: the interceptor
#    is on the path. No request is sent.
issuer="$(X sh -c "echo | openssl s_client -connect $HOST:443 -servername $HOST 2>/dev/null | openssl x509 -noout -issuer")"
case "$issuer" in *"smolvm $NAME credential CA"*) check interception on on ;; *) check interception "off (${issuer:-no answer})" on ;; esac

# 5. A host no binding names keeps its real certificate.
if [ -n "$OTHER" ]; then
    other_issuer="$(X sh -c "echo | openssl s_client -connect $OTHER:443 -servername $OTHER 2>/dev/null | openssl x509 -noout -issuer")"
    case "$other_issuer" in *"credential CA"*) check passthrough intercepted real ;; "") check passthrough no_answer real ;; *) check passthrough real real ;; esac
fi

# 6. The guard: a placeholder in the query string is refused here with a 403 and
#    never forwarded, so nothing leaves the host.
refusal="$(X sh -c "curl -s -w ' [%{http_code}]' \"https://$HOST/?probe=\$$VAR\"")"
case "$refusal" in *"[403]"*) check query_refused 403 403 ;; *) check query_refused "${refusal:-no answer}" 403 ;; esac
printf 'refusal_text=%s\n' "${refusal% \[*}"

if [ "$fail" -eq 0 ]; then printf 'result=contained\n'; else printf 'result=FAILED\n'; exit 1; fi
```

### `scripts/cleanup.sh`

```bash
#!/usr/bin/env bash
# Delete the machines this packet's scripts created, then prove the host is clean.
#
# Only machines recorded in the state file are deleted, with --cascade, so any
# machine branched from one of them goes too, whatever its name. Any other
# machine you or another session created by hand is never deleted. Scripts
# record a name by calling: cleanup.sh --record <name>
#
# usage: cleanup.sh [--record <name>] [--reap] [--purge]
#   --record <name>  add a machine name to the state file and exit
#   --reap           kill every VM process under this HOME; run without it first
#   --purge          also remove the state file once the list is empty

set -uo pipefail

PACKET="credentials"
PREFIX="smolskill-"

SMOLVM="${SMOLVM:-$(command -v smolvm 2>/dev/null)}"
STATE_DIR="${SMOLVM_SKILL_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/smolvm-skills}"
STATE_FILE="$STATE_DIR/$PACKET.machines"

reap=0
purge=0
while [ $# -gt 0 ]; do
    case "$1" in
        --record)
            mkdir -p "$STATE_DIR"
            printf '%s\n' "$2" >> "$STATE_FILE"
            exit 0
            ;;
        --reap)  reap=1 ;;
        --purge) purge=1 ;;
        *) printf 'unknown argument: %s\n' "$1" >&2; exit 2 ;;
    esac
    shift
done

if [ -z "$SMOLVM" ]; then
    printf 'smolvm not found; set SMOLVM to its path\n' >&2
    exit 2
fi

# With SMOLVM_DATA_DIR set, smolvm runs with HOME there on Linux, so its cache
# is under .cache in it.
case "$(uname -s)" in
    Darwin) VMS_DIR="$HOME/Library/Caches/smolvm/vms" ;;
    *)      cache_root="${SMOLVM_DATA_DIR:+$SMOLVM_DATA_DIR/.cache}"
            VMS_DIR="${cache_root:-${XDG_CACHE_HOME:-$HOME/.cache}}/smolvm/vms" ;;
esac
VMS_DIR="${SMOLVM_VMS_DIR:-$VMS_DIR}"
SMOLVM_PREFIX="${SMOLVM_PREFIX:-$HOME/.smolvm}"
if [ -n "${SMOLVM:-}" ]; then
    case "$(readlink "$SMOLVM" 2>/dev/null || printf '%s' "$SMOLVM")" in
        "$SMOLVM_PREFIX"/*) ;;
        *) printf 'note=%s is not under %s, so the process scan cannot see its forked VMs (all of its VMs on macOS); set SMOLVM_PREFIX to its directory\n' "$SMOLVM" "$SMOLVM_PREFIX" ;;
    esac
fi

# List this HOME's VM processes as "pid marker". The plain run path execs a
# `_boot-vm` child that carries its boot config; the pack-run path forks one that
# carries none. Linux names both `libkrun VM`, or `VM:<hostname>` when HOSTNAME
# is exported; on macOS the executable path and the parent chain scope the
# search. The teardown packet's traps have the why.
list_vm_processes() {
    case "$(uname -s)" in
        Linux)
            for p in /proc/[0-9]*; do
                case "$(cat "$p/comm" 2>/dev/null)" in "libkrun VM"|VM:*) ;; *) continue ;; esac
                pid="${p#/proc/}"
                cfg="$(tr '\0' '\n' < "$p/cmdline" 2>/dev/null | sed -n '3p')"
                case "$cfg" in
                    "$VMS_DIR"/*) printf '%s %s\n' "$pid" "$cfg"; continue ;;
                esac
                case "$(readlink "$p/exe" 2>/dev/null)" in
                    "$SMOLVM_PREFIX"/*) printf '%s forked-under %s\n' "$pid" "$SMOLVM_PREFIX" ;;
                esac
            done
            ;;
        Darwin)
            # shellcheck disable=SC2009  # pgrep -f would match this script.
            procs="$(ps -axo pid=,ppid=,command= 2>/dev/null)"
            printf '%s\n' "$procs" | while read -r pid ppid rest; do
                case "$rest" in "$SMOLVM_PREFIX"/smolvm-bin*) ;; *) continue ;; esac
                case "$rest" in
                    *" _boot-vm "*) printf '%s %s\n' "$pid" "${rest#* _boot-vm }"; continue ;;
                esac
                # An orphan counts only if it is a run; a fork has its parent's command line.
                if [ "$ppid" = 1 ]; then
                    case "$rest" in
                        *" machine run "*|*" vm run "*|*" pack run "*)
                            printf '%s orphaned-under %s\n' "$pid" "$SMOLVM_PREFIX" ;;
                    esac
                else
                    parent="$(printf '%s\n' "$procs" | while read -r q qp qrest; do [ "$q" = "$ppid" ] && { printf '%s' "$qrest"; break; }; done)"
                    [ "$parent" = "$rest" ] && printf '%s forked-under %s\n' "$pid" "$SMOLVM_PREFIX"
                fi
            done
            ;;
    esac
}

# 1. Delete recorded machines. Without --force a delete prompts and defaults to
# No; --cascade removes branch children, which otherwise block it.
if [ -s "$STATE_FILE" ]; then
    while read -r name; do
        [ -n "$name" ] || continue
        case "$name" in "$PREFIX"*) ;; *)
            printf 'skipping %s: not created by this packet (no %s prefix)\n' "$name" "$PREFIX"
            continue ;;
        esac
        # Only names still listed: a second stop of a missing name leaves a
        # directory that reads as a leak. The list is read first because grep -q
        # under pipefail can fail the pipeline and skip a listed machine.
        listed="$("$SMOLVM" machine list </dev/null 2>/dev/null | awk 'NR>2{print $1}')"
        grep -qx -- "$name" <<<"$listed" || continue
        "$SMOLVM" machine stop   --name "$name" </dev/null >/dev/null 2>&1
        "$SMOLVM" machine delete --name "$name" --force --cascade </dev/null 2>&1 | sed 's/^/  /'
    done < "$STATE_FILE"
fi

# 2. An ephemeral machine's entry retires after its run returns: poll, 20 s at most.
printf 'waiting=up to 20s for ephemeral entries to retire before asserting\n'
waited=0
listing="$("$SMOLVM" machine list 2>&1)"
while ! grep -q 'No machines found' <<<"$listing" && [ "$waited" -lt 20 ]; do
    sleep 1; waited=$((waited + 1))
    listing="$("$SMOLVM" machine list 2>&1)"
done
printf 'waited=%ss\n' "$waited"

# 3. Assert the value, not the exit code.
if grep -q 'No machines found' <<<"$listing"; then
    printf 'machines=clean\n'
    [ "$purge" -eq 1 ] && rm -f "$STATE_FILE"
else
    printf 'machines=remaining\n'
    printf '%s\n' "$listing" | sed 's/^/  /'
    printf 'note=this packet did not create these, so it will not delete them. By name:\n'
    printf '  smolvm machine stop --name <NAME> && smolvm machine delete --name <NAME> --force\n'
    printf '  add --cascade for a machine that was branched from another\n'
fi

# 4. Report VM processes under this HOME's state, such as a killed wrapper's.
# With --reap every one is killed, including a machine another packet kept.
found=0
while read -r pid cfg; do
    [ -n "$pid" ] || continue
    found=1
    printf 'vm_process=%s config=%s\n' "$pid" "$cfg"
    if [ "$reap" -eq 1 ]; then
        kill -9 "$pid" 2>/dev/null && printf '  killed %s\n' "$pid"
    fi
done <<EOF
$(list_vm_processes)
EOF

if [ "$found" -eq 0 ]; then
    printf 'vm_processes=none\n'
elif [ "$reap" -eq 0 ]; then
    printf 'rerun with --reap to kill them\n'
fi
```

## Credential substitution traps

Each entry was measured on v1.18.2 on 2026-09-24 on macOS arm64 and Linux aarch64, with a random
throwaway value, unless it says otherwise.

### An environment value behaved as the one `machine start` saw

The interceptor runs in the host process that `machine start` launched, and resolves a value from
the host environment out of that process. So:

- A machine started with the variable set kept substituting after the variable was unset for a
  `machine exec`: the request went out with the start-time value and the API answered `200`
  (macOS only; not repeated on Linux, since it sends the value).
- The same machine stopped and started with the variable unset answered every substituted request
  with `smolvm credentials: credential unavailable` and a `502`, and forwarded nothing.

`docs/credential-substitution.md` says an environment value is read at `machine start` and
`machine exec` time; what was observed is start time. Rotate a value from the environment by restarting the machine, and use a
file reference for a value that has to rotate under a running machine.

### A check that interpolates the value plants it

`machine exec` commands are written to the machine's console log on the host, `agent-console.log`
in its data directory. A containment check that ran
`smolvm machine exec -- sh -c "grep -r \"$VALUE\" /proc/*/environ ..."` put the value into that log
and then found it in the machine's record. Pass it on standard input, as
`scripts/verify-containment.sh` does
(`printf '%s\n' "$VALUE" | smolvm machine exec -i ... grep -F -f /dev/stdin ...`), and never paste
a real key into an `exec` command for any reason.

### A 403 or 502 with a `smolvm credentials:` body is smolvm, not the API

Measured refusals, each decided on the host before anything was forwarded:

| request from the guest | answer |
|---|---|
| placeholder in the query string | `403 smolvm credentials: placeholders are substituted in request headers only` |
| placeholder in a form body | the same; from v1.27.0 `403 smolvm credentials: body substitution is disabled for this binding`, unless the binding has `:body` |
| the placeholder in two headers | `403 smolvm credentials: a request may carry one placeholder` |
| a made-up `SMOL_PLACEHOLDER_DEMO_...` | `403 smolvm credentials: unknown placeholder` |
| placeholder in `Cookie` | `403 smolvm credentials: placeholders are not substituted in routing or framing headers` |
| any substituted request, machine started without the value | `502 smolvm credentials: credential unavailable` |

`docs/credential-substitution.md` puts a binding that does not allow the host or the method under
a `405`. The host case is a `403`, `smolvm credentials: this credential is not allowed for this
host`, measured on v1.22.2 on macOS arm64 with two bindings. A method the binding does not allow
is a `405`, not measured here. A request to the bound host with no placeholder was forwarded and
answered normally.

### The create-time rules

Measured on v1.22.2:

```
credential "t" host "*.example.com" must be an exact lowercase DNS name (no wildcard, scheme, port or IP)
credential "t" host "93.184.215.14" must be an exact lowercase DNS name (no wildcard, scheme, port or IP)
credential "t" host "example.com" is not reachable under the machine's network allow_hosts
```

The last is a machine with `--allow-host example.org` and a credential for `example.com`: a
credential never widens what the machine may reach.

From v1.26.0 a host may be a whole-label wildcard, `*.example.com`, matching every subdomain and not
the apex; `*.com` and other one-label parents are refused. Under `--allow-host`, a wildcard binding
needs an entry that admits the whole subtree (`example.com` or `*.example.com`, not
`api.example.com`). The IP refusal now reads `must be a lowercase DNS name, optionally` followed by
the wildcard form. Read from the source at v1.27.3, not run here.

### Port 80 and other ports are not intercepted

A plain `http://` request to the bound host went straight through and was answered `200`. A
placeholder sent that way would reach the server as the literal string. Only port 443 is
intercepted.

### Which network backend carries it

A machine with a binding came up on virtio-net, `eth0 100.96.0.2/30`, with no backend named. With
`--net-backend tsi` it came up on TSI, `dummy0 203.0.113.1/24`, and the bound host's certificate was
still issued by the machine's credential CA and a placeholder in a query still got the `403`: TSI
carries the interceptor when libkrun exports `krun_set_stream_intercept`, which both v1.18.2
release libraries do, and `scripts/preflight.sh` reads that symbol. A guest that runs a VPN on the
virtio-net link needs `--guest-subnet`, which the `throwaway-machine` packet's traps cover.

### What the guest has, and what it does not

Inside the guest: the variable holds `SMOL_PLACEHOLDER_<NAME>_<32 hex>`; `/run/smol/credentials/`
holds only `ca.pem`; `SSL_CERT_FILE` and `CURL_CA_BUNDLE` point at `/run/smolvm/ca-bundle.pem` and
`NODE_EXTRA_CA_CERTS` at `ca.pem`. The value was in no process environment and no file under `/run`,
`/etc`, `/root`, `/tmp`, `/var` or `/home`, and not in the machine's directory or smolvm's database
on the host. A checkpoint of a credentialed machine, extracted on macOS, did not contain it either; its
contents are compressed, so the stronger evidence is that the guest never held the value to begin
with.

### `--credential` on a machine created from a pack is dropped on v1.18.2

`smolvm machine create --name <n> --from app.smolmachine --credential demo=VAR@example.com`
succeeded, and after `machine start` the guest's `VAR` was empty and `/run/smol/credentials` did
not exist: no placeholder, no CA, no binding. Measured on macOS arm64. The fix landed upstream after
the release (#1400, which also makes `--credential` on a restore from a `.smolcheckpoint` an
explicit error, since a checkpoint keeps the bindings it was captured with). On v1.18.2 create the
credentialed machine from an image, which is what `scripts/create-credentialed.sh` does.

**On v1.22.2 it binds.** The same `create --from` of an image pack with `--credential`, then
`machine start`, measured on macOS arm64 on 2026-10-03: one `SMOL_PLACEHOLDER` variable in the
guest's environment and `/run/smol/credentials/ca.pem` present.
