Skip to content

Config subsystem (koanf): typed config, fail-closed, no self-disable knob#7

Open
Tombar wants to merge 7 commits into
mode-namespace-renamefrom
config-koanf
Open

Config subsystem (koanf): typed config, fail-closed, no self-disable knob#7
Tombar wants to merge 7 commits into
mode-namespace-renamefrom
config-koanf

Conversation

@Tombar

@Tombar Tombar commented Jun 10, 2026

Copy link
Copy Markdown
Member

Summary

Introduces a unified config subsystem (koanf, local providers) that replaces scattered env-vars + hardcoded paths with one typed Config, loaded once at startup and consumed by the hook and every CLI subcommand. Fully backward-compatible — no config file means identical behavior.

Stacked on #6 (the enabled/disabled + guard→failsafe rename). Review/merge after #6.

  • Loader (internal/config) — typed Config, precedence flags > env > file > defaults, providers file (YAML) + env (FAILSAFE_ prefix) + posflag. Config file at ~/.config/failsafe/config.yaml. koanf versions pinned.
  • Fail-closed Validate() — a bad/invalid config blocks (the hook exits non-zero before any command runs): log.redact:false rejected, control_plane.* reserved/rejected, malformed YAML rejected. Safety is never negotiable at the config layer.
  • No self-disable knobmode.default is not configurable; the default guard mode is hardwired enabled (protected). The only way to allow writes is the per-pane toggle, which is already self-protected.
  • Schema (10 keys): mode.pane_dir, log.{enabled,path,redact} (redact safety-fixed true), telemetry.{enabled,otlp_endpoint} (off by default, no-op stub — no OTLP SDK), policy.{user_path,tools_dir}, trust.path. control_plane.* reserved.
  • Back-compatFAILSAFE_MODE stays a mode-chain source (not collapsed into config); FAILSAFE_LOG (off/<path>) reproduces auditlog.DefaultLogger exactly.
  • Self-protection — config path fixed in code; Validate rejects fail-open knobs; documented residual surface + a filesystem-access guard roadmap entry (the real fix for protecting config.yaml/~/.aws/~/.ssh/policies from agent writes — separate future PR).
  • Wired into hook (chain/logger/policy/tools/trust from Config) + CLI (toggle/mode/trust/tools/report); docs (configuration.md rewrite) + CHANGELOG.

Test Plan

  • go test ./... — 14/14 packages pass (incl. precedence, missing-file-defaults, FAILSAFE_LOG/MODE back-compat, fail-closed Validate, multi-word env mapping, telemetry off-by-default)
  • failsafe test ./test/corpus — 30/30 (hook exercised with no config → defaults → identical)
  • Final security review — 5/5 invariants pass (behavior-identical, fail-closed-on-bad-config, no fail-open, FAILSAFE_MODE not collapsed, FAILSAFE_LOG exact); one must-fix (multi-word env mapping) found + fixed
  • CI green on push

🤖 Generated with Claude Code

Tombar and others added 7 commits June 16, 2026 07:05
… = defaults)

Introduces internal/config: a layered koanf-based config loader (defaults →
file → env → flags) with typed Config struct, FAILSAFE_LOG back-compat shims,
tilde/\${VAR} path expansion, and Validate() enforcing safety invariants
(log.redact fixed-true, control_plane.* reserved-v1, mode.default fail-safe
normalisation with explicit disabled allowed per O1).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…= identical behavior)

Wire internal/config into the hook enforcement path:
- cmd/failsafe/main.go: load config once at top of run(), fail-closed on bad
  config file, pass cfg into HookOptions{Cfg: cfg} for the hook case (both
  explicit 'hook' subcommand and the default bare invocation).
- internal/subcommand/hook.go: add Cfg *config.Config to HookOptions; if nil,
  load defaults via config.Load (single code path). Replace defaultModeChain()
  with buildModeChain(cfg, home) driven by cfg.Mode.PaneDir / cfg.Mode.Default.
  Replace auditlog.DefaultLogger with loggerFromConfig(cfg). Replace hardcoded
  trust path / user policy path / tools dir with cfg.Trust.Path,
  cfg.Policy.UserPath, cfg.Policy.ToolsDir.
- internal/policy/chain.go: add UserPolicyPath to DiscoverOpts so callers can
  supply a config-driven path without knowing HOME.
- internal/trust/trust.go: add LoadFromPath(path) so the hook can load from the
  config-driven path; Load(home) delegates to it (no behavior change).
- internal/config/config.go: fix injectableEnvProvider to skip single-segment
  FAILSAFE_* keys (e.g. FAILSAFE_MODE) that map to struct fields, not leaves —
  prevents unmarshal errors when tests set FAILSAFE_MODE in the env.

All existing hook/parity/mcp/corpus tests pass unchanged (no-config-file path
= defaults = identical behavior). New TestBuildModeChain_CustomPaneDir proves
config.Mode.PaneDir drives the chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…from one source)

Load config.Config once in run() and wire cfg.Trust.Path → TrustOptions,
cfg.Policy.ToolsDir → ToolsListOptions, cfg.Log.Path → ReportOptions.LogPath,
cfg.Mode.Default → ExplainOptions.Mode, and build the mode chain from cfg for
toggle/mode/mode-set via the new exported ModeChainFromConfig helper. Removes
the auditlog.DefaultLogger import from main.go. Adds TestTrust_CustomTrustPath
and TestToolsList_CustomToolsDir to pin that config-driven paths are actually
honoured.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
… (hardcoded, no self-disable vector)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…OG, self-protection note + fs-guard roadmap

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@Tombar Tombar force-pushed the mode-namespace-rename branch from c9caa7a to 4c001ae Compare June 16, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant