ADR-0005 — Secretless by default: posture axes/rungs as data, TTLs, host-side state¶
- Status: Accepted (2026-06; four-axis form landed 2026-07-02, PR #25) — implemented.
Amended 2026-09-24: the
captureaxis is gone — the proxy always decrypts, and[proxy] passthroughis the one decryption control — see ADR-0029. The model stands under new names: posture → mode,Axis→Switch,rungs→levels(plugin API and config; see ../glossary.md). - Sources: PLAN.md §4.1 + §6, README.md thesis 2, docs/history/per-consumer-registry-plan.md
Context¶
The yard's promise is that untrusted code — an agent, an npm install, a lifecycle script —
wakes up with zero ambient credentials. But real development sometimes needs real access:
reading staging logs, pushing a PR as a bot, hitting a live LLM. If those grants are ad-hoc
(an env var someone exports, a token file someone mounts), the zero-credential default erodes
one convenience at a time, and there is no way to see what the current exposure is, let alone
assert it in verify.
We needed a posture model where (a) the resting state is provably secretless, (b) every grant is an explicit, visible, reversible declaration, (c) high-privilege grants cannot linger by accident, and (d) nothing running inside the boundary can widen it.
Decision¶
Posture is data, not code paths. Each credential dimension is an Axis
(src/foldyard/plugins/__init__.py): a name, an ordered tuple of rungs (least → most
privileged), a per-rung blurb, the daemon its status maps to, and which rungs are emergency.
Plugins define axes; the substrate (src/foldyard/devmode.py) validates, persists, and
enforces them uniformly. A consumer's active axis set is a pure function of its resolved config
(the per-consumer registry), so fy mode never advertises a posture the repo can't back.
rungs[0]is the axis's zero-secret default — the rung every axis rests at and reverts to. It is not forced to be"off":auth0defaults tosim(an offline simulator is the secretless resting state),storagetolocal,llmtooff. The default-rung invariant is structural:Axis.__post_init__refuses a default that is also an emergency rung.- Emergency rungs carry a mandatory TTL. Rungs declared
emergency(e.g.gcp=user,github=user— "act as me" grants) get anexpiresstamp on everyset_mode(DEFAULT_TTL= 1 h). The supervisor's reconcile loop applies expiry to the authoritative file (expire_user_modes), flipping the axis back to its default durably and stopping the daemon — an expiry is a de-escalation and is never refused, so emergencies structurally cannot linger. - Authoritative state lives host-side, outside the mount: per-worktree posture under
~/.foldyard/<project>/<worktree-or-main>/dev-mode.json(config.posture_dir()), with shared identity (host.env, the supervisor lock, the CA) at the project root. The box sees only a read-only, gitignored mirror (.dev-mode.jsonin the repo,config.mirror_file()) that the supervisor refreshes with daemon health while that worktree's box is up — informational only, never read for enforcement. - The box cannot escalate its own posture.
devmode.set_modehard-refuses underin_box(): mode changes are host-only, because the file that matters is in the host's home where no in-yard process can reach it. Editing the mirror changes nothing. - No daemon running ⇒ no credential flows. Daemons (minters, the injecting proxy) are the only path a real credential takes, and the supervisor runs exactly the daemons the current mode demands (ADR-0006). Whatever a file inside the yard claims, a stopped daemon mints nothing.
- Coherence is guarded, not assumed:
Plugin.mode_issues(mode)lets plugins veto nonsensical combinations (set_moderefuses"error"issues with the full atomic fix in the message;"warn"s print), andPlugin.compose_overlays(mode)stacks each posture's compose overlays so several axes can shape the stack at once. Stale mode-file keys (an axis the current config no longer activates) are dropped on read by construction.
Consequences¶
- The secretless claim is checkable:
fy modeshows the exact posture,verifyasserts mode-aware invariants (dummy-token-OK / real-token-FAIL), and the TUI renders the same data. - Adding a credential mechanism means declaring an axis, not threading a new flag — the TTL, mirror, refusal-in-box, and reconcile behaviour come for free.
- A worktree is the posture unit (ADR-0016): parallel branches hold different postures without either seeing the other's tokens.
- The cost: posture is one more state file to reason about, and the supervisor must be running
for TTL expiry to fire (mitigated:
read(apply_expiry=True)also applies expiry on read, so a lapsed emergency is never reported as live even with no supervisor).
Rejected alternatives¶
- Credentials baked into box env/config at creation — invisible, unbounded lifetime, and revocation would need a re-bake; the exact ambient-credential model the product exists to end.
- A single online/offline switch — real needs are per-mechanism (staging logs without LLM spend, GitHub App without GCP); one boolean forces all-or-nothing escalation.
- Forcing
rungs[0] == "off"— pre-#25 shape; it conflated "zero-secret" with "disabled" and made offline simulators (auth0=sim) look like exceptions instead of defaults. - Enforcing posture from inside the box (a guard the box consults) — anything in-yard is inside the blast radius and can be patched out by the code it is supposed to constrain.