Skip to content

ADR: Credentialed (trusted-tier) runtime contract

Status

Accepted (2026-06-08). The gateway flavour landed on feat/privsep-uid-separation and was proven end-to-end on the Dell: a cap-only gateway (CAP_SETUID/SETGID, no root) drops a trusted plugin to a real login uid with HOME=/home/<user>, reaching that user's on-disk creds via ~, while a confined plugin on the same gateway stays walled and cannot read secret zero. Design agreed in the privsep war-room (operator + ThinkPad-ductile-admin + MacM1).

Context

The confined runtime contract walls a plugin off: it drops to a throwaway account and its HOME/XDG_CACHE_HOME/cwd are rebased to a private state_dir. That is correct for the standard and untrusted tiers. But a homelab has a small set of plugins that must act as the operator — push to git with the operator's ssh key, use their gh token — and reaching those on-disk creds (~/.ssh, ~/.config/gh) requires the plugin's HOME to be the operator's real home, not a walled state_dir.

The operator's framing settled the naming: this set is trusted, not "difficult". A plugin is in this tier because the operator vouches for its code and accepts that its compromise == the operator's compromise. That is a deliberate trust grant, distinct from "needs a secret" (a read-only plugin that needs one vault token stays confined — see github_repo_sync).

Two prior findings constrain the design: - No root needed. A cap-only gateway already holds CAP_SETUID, which drops to any uid including the operator's. So the trusted tier is run_as: <operator> on the same gateway — not a second instance, not a root gateway (see #111: root-hybrid rejected). - Reachability. The gateway must read the trusted plugin's code (discovery + fingerprint), but the code lives in the operator's 0700 home. A ductile-group ACL (g:ductile:rX on the plugin dir, g:ductile:x on the home) grants the gateway code-read only — never the creds, and never to the confined accounts. This is preferred over opening the home 0711 to the world.

Decision

Introduce a third account flavour alongside unconfined and confined: credentialed. It is marked by a home: on the account in config. A credentialed account drops to its uid exactly as a confined account does — the privilege gate is identical — but its runtime is rooted at the real home, not a walled state_dir. It is the inverse of the confined (C) contract.

accounts:
  default:   { uid: 1001, gid: 1001, state_dir: /var/lib/ductile/accounts/default }  # confined
  trusted:   { uid: 1000, gid: 1000, home: /home/matt }                              # credentialed

A credentialed plugin gets, at spawn: - the drop to uid:gid (CAP_SETUID/SETGID — never root; same Validate invariant as confined); - HOME = the account's real home (the gateway's own HOME is dropped, never leaked), so ~/.ssh, ~/.config/gh, and the git credential helper resolve; - cwd = the real home; - operator-granted plugin_env_passthrough env (e.g. SSH_AUTH_SOCK) preserved untouched; - groups-minimal by default — the drop resets supplementary groups to [gid], so a trusted plugin gets the operator's files (uid-based) but NOT the operator's group powers (docker = root is a supplementary group). Docker (or any supplementary group) is an explicit per-plugin opt-in, parked until needed. This keeps "trusted = acts as my identity", not "acts as root".

A credentialed plugin must NOT be assumed walled: its compromise reaches everything the operator's uid can. The entry bar is therefore the discipline the name buys — reviewed/authored code, no deps fetched at spawn, never pipe external/webhook input into a shell or git arg (there is no wall to catch a mistake in this tier).

Mechanism (where it lives)

The encoding and guards were shaped by a luminary grill (Hickey/Liskov/Armstrong/Ousterhout, 2026-06-08) before commit — see "Grill outcomes" below for the why. - Tier as a closed enum, not a bool+tag. ResolvedAccount.Mode is AccountMode (ModeUnconfined|ModeConfined|ModeCredentialed); AccountConf.Home selects the credentialed mode via the single constructor configuredAccount. Predicates: Drops() (Mode != Unconfined — the drop path), Credentialed() (Mode == Credentialed — the runtime), Walled() (Mode == Confined). - Validate switches on Mode. The privilege gate (uid>0, never root) is identical for both dropping tiers; credentialed additionally asserts at the seam that Home is absolute and not / (the seam does not trust how the value was built, not just config load). - subprocess_executor branches the runtime: Credentialed()withCredentialedHome (set HOME, drop the gateway's, leave XDG/passthrough) + cwd = home; Walled()withAccountRuntimeEnv (state_dir rebase). - The boot fs-reconcile verifies-but-does-not-mutate a credentialed account (verifyCredentialedHome): the home must exist, be a real directory (not a symlink), and be owned by the account uid — fail closed otherwise. The gateway never chmods the operator's home, but it does look (a botched home is caught at boot, not as a half-run at spawn). The Lstat doubles as the ductile-group ACL-reachability check (an unreadable home fails loudly); an unreadable plugin_root under it also hard-fails discovery at boot. - Config load rejects home: on the default/fallback tier: the trusted tier must be reached by an explicit run_as grant, never inherited by silence (an ungranted plugin must never default to running as a real user with their creds). - Every credentialed account is named loudly at boot (WARN … account is CREDENTIALED …) so the trust grant is auditable, never inferred from a field's presence.

Consequences

  • One gateway, trust-tiered. confined/untrusted plugins stay walled (vault, /opt code); trusted plugins run as the operator from ~/ductile/plugins (code read via the ductile group). Pipelines stay on one event bus.
  • Secret zero still holds for the confined side. The gateway-user owns the age key 0600; confined accounts can't read it. A trusted plugin runs as the operator, who may be root-equivalent (docker) — so the boot-time, tier-aware root-sidedoor audit (kanban #111) warns loudly: a confined account with a side-door is a broken wall (strict-mode fail-closed); a trusted account's root-equivalence is logged as informed consent.
  • run_as: matt for now; per-plugin tier review later (operator decision 2026-06-08). Plugins that only need a scoped secret should migrate back to confined+vault.

Grill outcomes (luminary panel, 2026-06-08)

The first cut encoded the tier as Confined bool + Home string with the runtime guards skipped; a four-lens grill before commit changed it (all "do not ship as-is"): - Hickey / Liskov — a bool+string is a leaky 2-valued encoding of a 3-valued domain; Confined silently widened from "walled" to "any drop". Liskov found a real second construction site (mostRestrictedAccount) that dropped Home. → adopt the closed AccountMode enum + a single constructor; the downgrade-is-always-walled is now explicit. - Armstrong — two fail-OPEN holes: (1) the default/fallback tier could silently be credentialed → every ungranted plugin runs as the operator (escalation); (2) fs-reconcile "skip" verified nothing about a credentialed home. Plus: Validate trusted the builder for Home. → ban home: on default, verify-don't-mutate the home (fail closed), assert Home at the seam. - Ousterhout — the out-of-band ductile-group ACL was an undefined error (silent absence at boot). → the home Lstat + discovery's hard-fail on an unreadable plugin_root make it loud; the credentialed tier is named at boot rather than inferred.