Runbook — first live privsep ENFORCE cutover (Thinkpad, Linux)¶
Status: first live enforce deployment (epic #83, the "one host → observe → next" step). Linux/cap-only path (proven on the Dell). Run on the Thinkpad. Reversible — keep the old service + binary until the wall-bite check (step 7) passes.
What changes (read first)¶
The Thinkpad runs ductile today as a --user LaunchAgent/systemd-user service as your own uid
(hygiene-only, unconfined). Enforce requires a system service running as the ductile account
with CAP_SETUID+CAP_SETGID, dropping each plugin to an account uid. That means the config,
state, vault, and age key move to ductile-owned system paths — the ductile user (uid 984) cannot
read files under your $HOME (0700). New topology (from deploy/systemd/):
today (--user) |
enforce (system) | |
|---|---|---|
| runs as | your uid | ductile (984) + CAP_SETUID/SETGID |
| config | ~/.config/ductile (or ~/admin/ductile-local) |
/etc/ductile (ductile-owned 0700) |
| state | under the above | /var/lib/ductile |
| age key | ~/.config/secrets/… |
inside /etc/ductile, ductile-owned 0600 |
| plugins | grants → ungranted = your uid | sys_exec → untrusted; others → default (uid 1001) |
The boot fs-reconcile tightens the config dir to 0700 ductile-owned (accounts can't read it) and
verifies each account's 0700 state_dir — fail-closed: a misconfig refuses to boot, it does not
half-confine.
0. Recon (on the Thinkpad — paste the output back to finalize per-plugin fixups)¶
cd /path/to/ductile && git fetch && git checkout feat/privsep-uid-separation && git pull
CFG=$HOME/.config/ductile # adjust if yours differs (e.g. ~/admin/ductile-local/config)
ductile config check --config "$CFG" --json | tee /tmp/ductile-preflight.json
# Enabled plugins + any operator-owned paths they read/write (these are the EACCES candidates):
grep -nE '^[a-zA-Z0-9_-]+:|enabled:|run_as:|allowed_(read|write)_paths:|output_dir:|root:|path:|working_dir:|command:' "$CFG/plugins.yaml"
ls -la "$CFG" # note where config/state/vault/age key live + current owner
systemctl --user status ductile-local 2>/dev/null | head -3 || launchctl list | grep ductile
Per-plugin rule: under enforce a plugin runs as its account uid → it can only write its own
state_dir, read world-readable code, and get secrets over stdin. Known breakage classes from the
audit: file_handler (writes allowed_write_paths), file_watch/folder_watch (read watched
paths), sys_exec (→ untrusted), and any plugin reading a secret from a $HOME dotfile (e.g.
fabric → ~/.config/fabric/.env) → move that secret into the vault or copy it into the account's
state_dir. Per your risk call, it's also fine to cut over and read the EACCES failures out of the job
logs, then fix the few that break.
1. Rollback point (do not skip)¶
ductile system backup --to ~/backups/pre-privsep-$(date -u +%Y%m%dT%H%M%SZ).tar.gz --scope config --config "$CFG"
cp "$(command -v ductile)" ~/backups/ductile-prev # keep the current (hygiene-only) binary
cp -a "$CFG" ~/backups/ductile-config-snapshot # plain copy of config+vault for hand-revert
2. Build + stage the branch binary (don't install yet)¶
cd /path/to/ductile && go build -o ~/staging/ductile-new ./cmd/ductile
~/staging/ductile-new --version # confirm it's the privsep branch build
3. Provision accounts as root (the deploy/systemd/ templates)¶
sudo install -m0644 deploy/systemd/ductile-accounts.sysusers.conf /etc/sysusers.d/ductile-accounts.conf
sudo install -m0644 deploy/systemd/ductile-accounts.tmpfiles.conf /etc/tmpfiles.d/ductile-accounts.conf
sudo systemd-sysusers # creates ductile(984) default(1001) untrusted(1002)
sudo systemd-tmpfiles --create /etc/tmpfiles.d/ductile-accounts.conf # creates the 0700 state dirs
id ductile ductile-default ductile-untrusted # verify they exist
4. Relocate config + secrets to ductile-owned system paths¶
sudo mkdir -p /etc/ductile
sudo cp -a "$CFG"/. /etc/ductile/ # config.yaml, plugins.yaml, pipelines.yaml, vault.age, .checksums, ductile.db
sudo cp -a ~/.config/secrets/ductile/age.key /etc/ductile/age.key # key moves INTO the (soon 0700) config dir
sudo chown -R ductile:ductile /etc/ductile /var/lib/ductile
sudo chmod 600 /etc/ductile/age.key
# Edit /etc/ductile/config.yaml as root:
# state.path: /var/lib/ductile/ductile.db (or keep under /etc/ductile if you copied the DB there)
# secrets.age_key_file: /etc/ductile/age.key
# accounts:
# default: { uid: 1001, gid: 1001, state_dir: /var/lib/ductile/accounts/default }
# untrusted: { uid: 1002, gid: 1002, state_dir: /var/lib/ductile/accounts/untrusted }
# plugins.sys_exec.run_as: untrusted # ungranted plugins fall back to default
sudo -u ductile /usr/local/bin/ductile config check --config /etc/ductile/config.yaml # MUST be clean
5. Per-plugin fs fixups (from step 0 / first-boot EACCES)¶
For each plugin that reads/writes an operator-owned path: repoint it under /var/lib/ductile/accounts/<tier>/…
(owned by the account), make the input world-readable, or move its secret into the vault. sys_exec's
working_dir must be owned by ductile-untrusted.
6. Cutover¶
sudo cp ~/staging/ductile-new /usr/local/bin/ductile
systemctl --user disable --now ductile-local 2>/dev/null || launchctl bootout gui/$(id -u)/com.mattjoyce.ductile-local
sudo install -m0644 deploy/systemd/ductile.service /etc/systemd/system/ductile.service
sudo systemctl daemon-reload && sudo systemctl enable --now ductile
7. VERIFY — including the wall-bite check (the part revert can't save you from)¶
journalctl -u ductile -n 60 # expect: "privsep enforcing: plugins drop to their resolved account"
# NOT "privsep boot gate ... refusing", NOT "privsep UNCONFINED"
curl -s localhost:8081/healthz # plugins_loaded == pre-deploy baseline
# run one job per enabled plugin; watch job logs for EACCES / "account uid/gid drop failed"
# WALL-BITE — prove the wall physically bites (availability revert won't catch a silent no-wall):
sudo -u ductile-default cat /etc/ductile/age.key # MUST print: Permission denied
sudo -u ductile-default ls /var/lib/ductile/accounts/untrusted # MUST print: Permission denied
# (optional, the headline threat) cross-uid ptrace between two plugins on DIFFERENT accounts must fail.
If the age key is readable by ductile-default, the wall is NOT real — stop and fix ownership before
trusting it.
8. Rollback (if it refuses to boot or breaks plugins you can't quickly fix)¶
sudo systemctl disable --now ductile
sudo cp ~/backups/ductile-prev "$(command -v ductile)"
systemctl --user enable --now ductile-local # or launchctl bootstrap the old LaunchAgent
# config/vault are untouched at $CFG; the /etc/ductile copy is additive and harmless to the old service.
Notes¶
- This is the first live enforce host. Observe it before the macOS rollout (#95) — and remember the Mac needs a different shape (root LaunchDaemon, no cap-only; see #102).
- The age key now lives inside the
0700ductile-owned config dir, so accounts can't read it. If you prefer it outside the bundle (ADR §8 floor), keep it root-owned0600at a pathductilecan read and pointsecrets.age_key_filethere — just confirm the wall-bite check still denies accounts.
Learnings from the first live run (2026-06-07) — VALIDATED¶
This runbook was executed live on the Thinkpad; the wall is proven (enforce + uid drop to 1002 + cross-account EACCES). What we learned:
- Deploy-as-NEW, don't migrate. The live config was too home-entangled to re-home (plugin code
under
~/Projects/*, ~12 secret.envunder~/.config/secrets/*, config under~/.config). A fresh/etc/ductile+/optcode + minimal confinable config booted enforce green in minutes; the migration would have been multi-hour with whole-gateway breakage. - Footgun (costs a fail-closed boot refusal):
chown -R ductile:ductile /var/lib/ductileclobbers the account-owned state dirs and trips the boot gate. chown only/etc/ductile;tmpfiles.downs the account dirs — leave them. - EMPTY DB for a newer binary. Carrying the old prod DB risks a schema-validation boot refusal vs a newer binary; a fresh DB sidesteps it entirely (history stays in the backup).
- Wall-proof that worked:
sys_exec(command=id)via API → stdoutuid=1002(ductile-untrusted)with groups reset to 1002 only;sudo -u ductile-default ls .../untrusted→Permission denied. - DX: config dir
0700ductile-owned →ductile job inspectas a human user hits EACCES; read job results via the API or as theductileuser. - Confinable vs unconfinable split:
docker compose/ apt scripts /fabric(~/.config/fabric) /file_handler(reads/home/matt) cannot run as an account uid — keep admin automation on an unconfined gateway. Restoring the confinable plugins enforced is Phase 2 (card #103).
Phase 2 learnings (2026-06-07, live)¶
plugin lockis a REQUIRED migration step, not an end-cap. Relocating plugin code to/optchanges its bytes' provenance → no recorded fingerprint → privsep's #93 binding refuses to honor thedefaultgrant and fails SAFE, downgrading the plugin to the least-priv tier (untrusted1002). The plugins still run, dropped + confined, just least-priv. Runductile plugin lock --allagainst the/opttree to record fingerprints, then they land on their intendeddefault(1001). Witnessed live: youtube_playlist + check_db_garmin both downgraded→untrusted until locked. (This is a clean in-the-wild proof of the #93 swap-defence.)- Plugin secrets are NOT a config reconcile.
PluginConfhas no nestedsecret_ref; plugin secret delivery is either a<field>_ref:key-suffix (loader substitution — unverified for plugins) or the spawn-time stdinsecretsmap (principal == plugin name, kebab-case only, and the plugin must read that map). Snake-case plugin names are rejected as principals (tested: 400). So secret-holding plugins need a dedicated vault-native workstream (card #107) — only keyless plugins restore via config alone. Worse, the vault compose path is fail-OPEN on an unknown principal (card #108) — the one non-fail-closed seam in an otherwise fail-closed design. migrate everything≠ everything enforced. The estate splits three ways: keyless-confinable (enforced now), secret-holding-confinable (enforced after #107), and unconfinable admin/docker (a second unconfined instance, card #106). The enforced gateway is the data plane, not the whole world.- Vault-native plugin recipe (#107, PROVEN 2026-06-07): see card #107 — read the stdin
secretsmap, manifest config_keys.required:[], register a kebab principal, setvault_principal+requires_vault, relocate+lock. First proof: discord_notify posts from the encrypted vault over stdin, uid 1001, fail-closed.
Redeploy / binary refresh (validated live 2026-06-07)¶
The repeatable way to push a new binary (validated on the live Thinkpad):
cd /path/to/ductile && git pull --ff-only && go build -o ~/staging/ductile-new ./cmd/ductile
sudo BIN_SRC=~/staging/ductile-new bash deploy/install.sh # idempotent FHS layer (ADR)
sudo systemctl restart ductile
sudo -u ductile ductile config check --config /etc/ductile/config.yaml # VALID
journalctl -u ductile -n 30 --no-pager # "privsep enforcing"
# verify a job still drops to its account (live `ps` shows uid 1001/1002), wall-bite holds
deploy/install.shis safe to re-run: idempotent; lays/asserts only the FHS structure + binary + unit; it does NOT touch config/vault/plugin code and does NOT chown/var/lib/ductile(no account-dir clobber). Confirmed live: created/run/ductile, account dirs intact, new binaryroot:root 0755.- A binary swap needs NO re-lock — plugin fingerprints are of the plugin code keyed by the vault
nonce, not the gateway binary;
config lock/plugin lockonly matter when config/plugin bytes change. - #101 verified live:
config checkon a config withaccounts:removed but secrets kept firesconfig is VALID but privsep is UNCONFINED … a popped plugin can read the decrypted secrets. And arun_aswith noaccounts:is a hard config-load error (fail-closed, not per-job). - #108 verified by unit tests:
plugins.<name>.requires_vault: truemakes an unknown principal fail the spawn closed (live demo skipped — it would break a plugin).