diff --git a/CHANGELOG.md b/CHANGELOG.md index 25043a5..5993fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **`~/.config/failsafe/config.yaml`** — new optional config file (koanf loader). + Precedence: `flags > env > file > defaults`. A missing file is equivalent to + all-defaults; no migration required. + +- **Config env var bindings** — `FAILSAFE_*` env vars now have documented config-key + mappings (e.g. `FAILSAFE_LOG_PATH` → `log.path`). The existing `FAILSAFE_MODE` and + `FAILSAFE_LOG` variables are handled by back-compat shims; their behavior is **unchanged**. + +- **`internal/telemetry` package** — minimal telemetry interface, off by default. + `New(cfg TelemetryConfig)` returns a no-op exporter when `telemetry.enabled` is + `false` (the default) and a stub (still no-op) when enabled. No OpenTelemetry + dependency is added in v1; the stub documents the intended OTLP payload shape for + review before a real exporter ships. + +- **Config self-protection documentation** (`docs/reference/configuration.md`) — explains + the four protection layers: fixed config path, `Validate()` rejecting fail-open knobs, + hardcoded default guard mode, and the residual filesystem-write surface. + +- **Filesystem-access guard roadmap entry** (`docs/explanation/roadmap.md`) — describes + the planned guard for agent writes to `~/.config/failsafe/`, `~/.aws/`, `~/.ssh/`, and + policy files. + ### Changed - **Canonical mode vocabulary**: the two mode values are now `enabled` (failsafe active, diff --git a/cmd/failsafe/main.go b/cmd/failsafe/main.go index 832a8be..e0a7edc 100644 --- a/cmd/failsafe/main.go +++ b/cmd/failsafe/main.go @@ -9,7 +9,7 @@ import ( "io" "os" - "github.com/UndermountainCC/failsafe/internal/auditlog" + "github.com/UndermountainCC/failsafe/internal/config" "github.com/UndermountainCC/failsafe/internal/subcommand" ) @@ -20,6 +20,14 @@ func main() { } func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + // Load config once at the top so all subcommands that need it can share it. + // Fail-closed: a bad config.yaml must not allow the hook to run permissively. + cfg, err := config.Load(config.Options{Home: os.Getenv("HOME"), Env: os.Getenv}) + if err != nil { + fmt.Fprintf(stderr, "failsafe: load config: %v\n", err) + return 1 + } + if len(args) >= 2 { switch args[1] { case "--version", "-v": @@ -29,7 +37,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { printHelp(stdout) return 0 case "hook": - return subcommand.Hook(stdin, stdout, stderr, subcommand.HookOptions{}) + return subcommand.Hook(stdin, stdout, stderr, subcommand.HookOptions{Cfg: cfg}) case "mcp": return subcommand.MCP(stdin, stdout, stderr) case "explain": @@ -37,6 +45,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return subcommand.Explain(args[2:], stdout, subcommand.ExplainOptions{ Home: os.Getenv("HOME"), CWD: cwd, + Mode: "enabled", // chain default is always hardcoded; no mode.default config key }) case "test": if len(args) < 3 { @@ -64,8 +73,9 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { case "trust": cwd, _ := os.Getwd() return subcommand.Trust(args[2:], stdout, subcommand.TrustOptions{ - Home: os.Getenv("HOME"), - CWD: cwd, + Home: os.Getenv("HOME"), + CWD: cwd, + TrustPath: cfg.Trust.Path, }) case "audit": cwd, _ := os.Getwd() @@ -74,22 +84,21 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } return subcommand.Audit(cwd, stdout, subcommand.AuditOptions{Home: os.Getenv("HOME")}) case "report": - // Read from wherever the hook writes: reuse DefaultLogger's path - // resolution so FAILSAFE_LOG and the default home path agree. - home := os.Getenv("HOME") + // LogPath comes from config so report reads from the same path the + // hook writes to (cfg.Log.Path already honours FAILSAFE_LOG shims). return subcommand.Report(args[2:], stdout, subcommand.ReportOptions{ - Home: home, - LogPath: auditlog.DefaultLogger(home, os.Getenv).Path, + Home: os.Getenv("HOME"), + LogPath: cfg.Log.Path, }) case "log": - home := os.Getenv("HOME") + // LogPath from config (same path the hook writes to; honours FAILSAFE_LOG). return subcommand.Log(args[2:], stdout, subcommand.LogOptions{ - Home: home, - LogPath: auditlog.DefaultLogger(home, os.Getenv).Path, + Home: os.Getenv("HOME"), + LogPath: cfg.Log.Path, }) case "toggle": return subcommand.Toggle(stdout, subcommand.ToggleOptions{ - Chain: subcommand.DefaultModeChain(), + Chain: subcommand.ModeChainFromConfig(cfg, os.Getenv("HOME")), Env: subcommand.EnvFromOS(), }) case "tools": @@ -97,7 +106,10 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "usage: failsafe tools list") return 2 } - return subcommand.ToolsList(stdout, subcommand.ToolsListOptions{Home: os.Getenv("HOME")}) + return subcommand.ToolsList(stdout, subcommand.ToolsListOptions{ + Home: os.Getenv("HOME"), + ToolsDir: cfg.Policy.ToolsDir, + }) case "policies": if len(args) < 3 || args[2] != "list" { fmt.Fprintln(stderr, "usage: failsafe policies list") @@ -116,7 +128,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { switch args[2] { case "get": return subcommand.ModeGet(stdout, subcommand.ModeOptions{ - Chain: subcommand.DefaultModeChain(), + Chain: subcommand.ModeChainFromConfig(cfg, os.Getenv("HOME")), Env: subcommand.EnvFromOS(), }) case "set": @@ -125,7 +137,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 2 } return subcommand.ModeSet(args[3], stdout, subcommand.ModeOptions{ - Chain: subcommand.DefaultModeChain(), + Chain: subcommand.ModeChainFromConfig(cfg, os.Getenv("HOME")), Env: subcommand.EnvFromOS(), }) default: @@ -136,7 +148,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } // No subcommand or unknown — default to hook mode (matches Claude Code's invocation). if hasNoFlagArgs(args[1:]) { - return subcommand.Hook(stdin, stdout, stderr, subcommand.HookOptions{}) + return subcommand.Hook(stdin, stdout, stderr, subcommand.HookOptions{Cfg: cfg}) } // Unknown invocation fmt.Fprintf(stderr, "failsafe: unknown invocation %v\n", args[1:]) diff --git a/docs/explanation/roadmap.md b/docs/explanation/roadmap.md index a0e85c9..3a0517d 100644 --- a/docs/explanation/roadmap.md +++ b/docs/explanation/roadmap.md @@ -93,6 +93,30 @@ If this matters for your use case, watch the [failsafe releases](https://github. --- +## Filesystem-access guard + +!!! warning "Roadmap: not yet shipped" + This section describes a planned future capability. It is not implemented today. + +The current config self-protection model (hardcoded defaults, `Validate()` rejecting +fail-open knobs) protects against accidental misconfiguration but cannot stop an agent +that writes directly to sensitive files on disk — because failsafe only guards registered +shell-tool invocations, not raw filesystem operations from the agent. + +The planned filesystem-access guard will extend failsafe's policy surface to cover +agent writes to sensitive paths, including: + +- `~/.config/failsafe/` — the failsafe config and policy files themselves +- `~/.aws/` — AWS credentials and config +- `~/.ssh/` — SSH keys and known-hosts +- User and repo Rego policy files (`.failsafe.rego`, `~/.config/failsafe/policy.rego`) + +With this guard in place, an agent attempting to weaken the failsafe configuration or +exfiltrate credentials via a file-write would be blocked by the same policy evaluation +that already blocks destructive infrastructure commands. + +--- + ## Where to go next - [Why failsafe](./why-failsafe.md): comprehension over matching diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 4ff4adc..1aea060 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -5,13 +5,114 @@ SPDX-License-Identifier: CC-BY-4.0 # Configuration Reference -All file paths and environment variables that configure failsafe, each with its default value and effect. +failsafe is configured through a layered system: command-line flags override environment +variables, which override the config file, which overrides compiled defaults. + +``` +flags > env > ~/.config/failsafe/config.yaml > defaults +``` + +A missing config file is equivalent to all-defaults. You only need a config file if you +want to change something from the default. + +--- + +## Config file + +**Path:** `~/.config/failsafe/config.yaml` + +The directory and file are created automatically at mode `0700`/`0600` on first use. +The path is fixed in code; the config file cannot repoint itself. + +### Full example (all defaults) + +```yaml +mode: + pane_dir: ~/.claude/pane-mode + +log: + enabled: true + path: ~/.config/failsafe/decisions.jsonl + redact: true # safety-fixed; false is a fatal error + +telemetry: + enabled: false # opt-in only; v1 exporter is a stub (no-op) + otlp_endpoint: "" + +policy: + user_path: ~/.config/failsafe/policy.rego + tools_dir: ~/.config/failsafe/tools + +trust: + path: ~/.config/failsafe/trusted-repos.yaml +``` + +### Key notes + +- **`mode.default` does not exist.** The default guard mode is hardcoded `enabled`; it + is not configurable. A configurable default would be a self-disable vector (an agent + editing config.yaml could bypass the guard). +- **`log.redact` is safety-fixed `true`.** Setting it to `false` is a fatal error at + load time. +- **`control_plane.*` is reserved in v1.** Setting either field is a fatal error at load + time. +- Tilde (`~`) and `${VAR}` are expanded in all path fields after loading. + +--- + +## Environment variable bindings + +FAILSAFE_* env vars are loaded after the config file and override it. + +### Config-backed env vars + +| Variable | Config key | Default | Effect | +|----------|-----------|---------|--------| +| `FAILSAFE_MODE_PANE_DIR` | `mode.pane_dir` | `~/.claude/pane-mode` | Toggle-file directory. | +| `FAILSAFE_LOG_ENABLED` | `log.enabled` | `true` | `false` disables all logging. | +| `FAILSAFE_LOG_PATH` | `log.path` | `~/.config/failsafe/decisions.jsonl` | Audit log file path. | +| `FAILSAFE_LOG_REDACT` | `log.redact` | `true` | Safety-fixed; `false` is a fatal error. | +| `FAILSAFE_TELEMETRY_ENABLED` | `telemetry.enabled` | `false` | Opt-in telemetry. v1 exporter is a no-op stub. | +| `FAILSAFE_TELEMETRY_OTLP_ENDPOINT` | `telemetry.otlp_endpoint` | `""` | OTLP collector URL (unused in v1). | +| `FAILSAFE_POLICY_USER_PATH` | `policy.user_path` | `~/.config/failsafe/policy.rego` | User Rego policy path. | +| `FAILSAFE_POLICY_TOOLS_DIR` | `policy.tools_dir` | `~/.config/failsafe/tools` | User tool definition directory. | +| `FAILSAFE_TRUST_PATH` | `trust.path` | `~/.config/failsafe/trusted-repos.yaml` | Trusted-repos list path. | + +### Back-compat env vars (unchanged behavior) + +These two variables predate the config file and are handled by special shims so their +existing behavior is preserved exactly. They are **not** direct config-key mappings. + +| Variable | Default | Effect | +|----------|---------|--------| +| `FAILSAFE_MODE` | (unset) | When set to `enabled` or `disabled`, overrides all file-based mode sources. Highest priority in the mode chain. Not mapped to a config struct field. Not affected by `failsafe toggle` or `mode set`. | +| `FAILSAFE_LOG` | (unset) | Legacy log control. `off` → logging disabled. Any other non-empty value → treated as an absolute path (sets `log.path` and `log.enabled=true`). Takes precedence over `FAILSAFE_LOG_PATH` / `FAILSAFE_LOG_ENABLED` when set. | + +### `FAILSAFE_LOG` values + +| Value | Behaviour | +|-------|-----------| +| unset | Log to `~/.config/failsafe/decisions.jsonl` (when `HOME` is set). | +| `off` | Logging disabled; no file is created or appended to. | +| `` | Log to the specified file. Directory is created if it does not exist. | + +### Other env vars + +| Variable | Default | Effect | +|----------|---------|--------| +| `HOME` | (OS-provided) | Used to resolve all `~/.config/failsafe/` and `~/.claude/` paths. Set explicitly in test environments. | +| `WEZTERM_PANE` | (unset) | Pane identifier for WezTerm. Enables priority-2 mode source `~/.claude/pane-mode/${WEZTERM_PANE}`. | +| `TMUX_PANE` | (unset) | Pane identifier for tmux. Enables priority-3 mode source `~/.claude/pane-mode/${TMUX_PANE}`. | +| `ITERM_SESSION_ID` | (unset) | Session identifier for iTerm2. Enables priority-4 mode source. | +| `KITTY_WINDOW_ID` | (unset) | Window identifier for Kitty. Enables priority-5 mode source. | +| `CLAUDE_SESSION_ID` | (unset) | Claude Code session identifier. Enables priority-6 mode source. | --- ## File paths -All paths under `~/.config/failsafe/` are created by failsafe as needed (mode 0700 for the directory, 0600 for log files). Paths under `~/.claude/` are shared with Claude Code. +All paths under `~/.config/failsafe/` are created by failsafe as needed (mode `0700` for +the directory, `0600` for log files). Paths under `~/.claude/` are shared with Claude Code. ### Policy files @@ -79,26 +180,46 @@ All mode files contain a single line (`enabled` or `disabled`). For the full res --- -## Environment variables +## Safety-fixed knobs -| Variable | Default | Effect | -|----------|---------|--------| -| `FAILSAFE_MODE` | (unset) | When set to `enabled` or `disabled`, overrides all file-based mode sources. Highest priority in the chain. Not affected by `failsafe toggle` or `mode set`. | -| `FAILSAFE_LOG` | (unset) | Controls where decisions are logged. Set to `off` to disable logging entirely. Set to an absolute path to log to that file instead of the default `~/.config/failsafe/decisions.jsonl`. | -| `HOME` | (OS-provided) | Used to resolve all `~/.config/failsafe/` and `~/.claude/` paths. Set explicitly in test environments. | -| `WEZTERM_PANE` | (unset) | Pane identifier for WezTerm. Enables priority-2 mode source `~/.claude/pane-mode/${WEZTERM_PANE}`. | -| `TMUX_PANE` | (unset) | Pane identifier for tmux. Enables priority-3 mode source `~/.claude/pane-mode/${TMUX_PANE}`. | -| `ITERM_SESSION_ID` | (unset) | Session identifier for iTerm2. Enables priority-4 mode source. | -| `KITTY_WINDOW_ID` | (unset) | Window identifier for Kitty. Enables priority-5 mode source. | -| `CLAUDE_SESSION_ID` | (unset) | Claude Code session identifier. Enables priority-6 mode source. | +The following configuration values cannot be changed: -### `FAILSAFE_LOG` values +| Knob | Fixed value | Reason | +|------|-------------|--------| +| `log.redact` | `true` | Prevents secrets in command arguments from reaching the audit log. Disabling would silently expose credentials. | +| `control_plane.url` | (must be empty) | Reserved for v2; not supported in v1. | +| `control_plane.bundle_signing_key` | (must be empty) | Reserved for v2; not supported in v1. | +| Default guard mode | `enabled` | Hardcoded in the mode chain; not a config value. A configurable default would allow an agent to disable the guard by editing config.yaml. | -| Value | Behaviour | -|-------|-----------| -| unset | Log to `~/.config/failsafe/decisions.jsonl` (when `HOME` is set). | -| `off` | Logging disabled; no file is created or appended to. | -| `` | Log to the specified file. The directory is created if it does not exist. | +Setting a rejected value causes `failsafe` to exit immediately with a fatal error, so a +misconfigured deployment fails visibly rather than silently bypassing the guard. + +--- + +## Config self-protection + +failsafe provides several layers of protection against an agent manipulating the +configuration to weaken the guard: + +1. **Config file path is fixed in code.** The path `~/.config/failsafe/config.yaml` is a + compile-time constant; no config value can repoint the config file to another location. + +2. **`Validate()` rejects fail-open knobs.** Any config file or env var that sets + `log.redact: false` or populates `control_plane.*` is rejected with a fatal error at + load time. The guard fails closed, not open. + +3. **Default guard mode is hardcoded `enabled`.** There is no `mode.default` config key. + The mode chain's default is the literal string `"enabled"`, set in code. An agent that + edits config.yaml cannot write a `mode.default: disabled` line — no such key exists. + +4. **Residual surface.** An attacker with shell write access to `~/.config/failsafe/` + could disable logging (`log.enabled: false`) or repoint `pane_dir` to a different + directory. This is part of the broader "protect sensitive files from the agent" problem + — the same access that lets an agent edit config.yaml lets it edit `~/.aws/credentials` + or `~/.ssh/id_rsa`. The solution is a **filesystem-access guard** (see the + [roadmap](../explanation/roadmap.md#filesystem-access-guard)) that blocks agent writes + to sensitive directories before they execute, rather than trying to harden individual + files post-write. --- diff --git a/go.mod b/go.mod index 41f4b34..b0bc8c0 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,13 @@ go 1.25.0 require mvdan.cc/sh/v3 v3.13.1 require ( + github.com/knadh/koanf/parsers/yaml v1.1.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/providers/posflag v1.0.1 + github.com/knadh/koanf/providers/structs v1.0.0 + github.com/knadh/koanf/v2 v2.3.5 github.com/open-policy-agent/opa v1.15.2 + github.com/spf13/pflag v1.0.10 gopkg.in/yaml.v3 v3.0.1 ) @@ -13,9 +19,13 @@ require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/fatih/structs v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.0.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect @@ -23,6 +33,8 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect diff --git a/go.sum b/go.sum index c7a13e4..9725c4c 100644 --- a/go.sum +++ b/go.sum @@ -23,16 +23,22 @@ github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7c github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= @@ -45,6 +51,18 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y= +github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk= +github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ5KvfgMbio+23u4= +github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w= +github.com/knadh/koanf/v2 v2.3.5 h1:2dXJUYaKGm4SGYeoAtBviq9+02JZo/pxQ2ssOd60rJg= +github.com/knadh/koanf/v2 v2.3.5/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -65,6 +83,10 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/open-policy-agent/opa v1.15.2 h1:dS9q+0Yvruq/VNvWJc5qCvCchn715OWc3HLHXn/UCCc= @@ -90,6 +112,8 @@ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fcec771 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,512 @@ +// Copyright 2026 Undermountain Coding Company +// SPDX-License-Identifier: Apache-2.0 + +// Package config provides a typed, layered configuration loader for failsafe. +// Providers are composed lowest-to-highest (defaults → file → env → flags), +// so flags always win and a missing config file is equivalent to all-defaults. +// +// Safety invariants enforced at load time (Validate): +// - log.redact is always true; false is a fatal error. +// - control_plane.url / control_plane.bundle_signing_key are reserved (v1); setting either is fatal. +// +// The chain default (mode.Chain.Default) is always the hardcoded literal "enabled". +// There is no mode.default config key — a configurable default would be a +// self-disable vector (an agent editing config.yaml could bypass the guard). +// +// Back-compat shims (applied before the env provider loads): +// - FAILSAFE_LOG=off → log.enabled=false +// - FAILSAFE_LOG= → log.path=, log.enabled=true +// - FAILSAFE_MODE is intentionally NOT mapped to any config field; it stays as the +// first source in the mode chain (EnvSource{Name:"FAILSAFE_MODE"}) so per-pane +// file sources can still override it. +package config + +import ( + "errors" + "fmt" + "strings" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/providers/posflag" + "github.com/knadh/koanf/providers/structs" + "github.com/knadh/koanf/v2" + "github.com/spf13/pflag" +) + +// --------------------------------------------------------------------------- +// Typed config structs (koanf-tagged) +// --------------------------------------------------------------------------- + +// Config is the root configuration struct. Callers receive a *Config from +// Load; every field is guaranteed to be non-zero after a successful load. +type Config struct { + Mode ModeConfig `koanf:"mode"` + Log LogConfig `koanf:"log"` + Telemetry TelemetryConfig `koanf:"telemetry"` + Policy PolicyConfig `koanf:"policy"` + Trust TrustConfig `koanf:"trust"` + ControlPlane ControlPlaneConfig `koanf:"control_plane"` +} + +// ModeConfig holds mode-resolution settings. +type ModeConfig struct { + // PaneDir is the toggle-file directory. Effectively fixed at + // ~/.claude/pane-mode; see spec §5 for why this is "recorded not driven". + // + // Note: there is intentionally no Default field here. The chain's default + // is always the hardcoded literal "enabled" (set in buildModeChain) — a + // configurable default would be a self-disable vector. + PaneDir string `koanf:"pane_dir"` +} + +// LogConfig controls the audit log. +type LogConfig struct { + Enabled bool `koanf:"enabled"` + Path string `koanf:"path"` + // Redact is safety-fixed true; Validate rejects false with a fatal error. + Redact bool `koanf:"redact"` +} + +// TelemetryConfig is OFF by default; opt-in only. v1 exporter is a stub. +type TelemetryConfig struct { + Enabled bool `koanf:"enabled"` + OTLPEndpoint string `koanf:"otlp_endpoint"` +} + +// PolicyConfig locates user-supplied Rego and tool YAML overrides. +type PolicyConfig struct { + UserPath string `koanf:"user_path"` + ToolsDir string `koanf:"tools_dir"` +} + +// TrustConfig locates the trusted-repos file. +type TrustConfig struct { + Path string `koanf:"path"` +} + +// ControlPlaneConfig is defined for forward-compat but rejected at load in v1. +// Setting either field is a fatal error ("not supported in v1"). +type ControlPlaneConfig struct { + URL string `koanf:"url"` + BundleSigningKey string `koanf:"bundle_signing_key"` +} + +// --------------------------------------------------------------------------- +// Load options +// --------------------------------------------------------------------------- + +// Options controls how Load resolves paths and reads the environment. +// Mirrors the home+env testability pattern used by auditlog.DefaultLogger. +type Options struct { + // Home is the user home directory. Falls back to os.Getenv("HOME") when empty. + Home string + // Env is the environment accessor. Falls back to os.Getenv when nil. + Env func(string) string + // Flags is the parsed pflag.FlagSet. Nil means skip the flags provider. + Flags *pflag.FlagSet +} + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +// defaults returns the compile-time default Config. Tilde paths are expanded +// by expandPaths after unmarshal, so we record them verbatim here. +func defaults() Config { + return Config{ + Mode: ModeConfig{ + PaneDir: "~/.claude/pane-mode", + }, + Log: LogConfig{ + Enabled: true, + Path: "~/.config/failsafe/decisions.jsonl", + Redact: true, + }, + Telemetry: TelemetryConfig{ + Enabled: false, + OTLPEndpoint: "", + }, + Policy: PolicyConfig{ + UserPath: "~/.config/failsafe/policy.rego", + ToolsDir: "~/.config/failsafe/tools", + }, + Trust: TrustConfig{ + Path: "~/.config/failsafe/trusted-repos.yaml", + }, + ControlPlane: ControlPlaneConfig{}, + } +} + +// --------------------------------------------------------------------------- +// Load +// --------------------------------------------------------------------------- + +// Load builds and returns the effective *Config by composing providers +// lowest-to-highest: defaults → config file → env → flags. +// +// A missing config file is not an error (§Migration D1). Env shims for +// FAILSAFE_LOG are applied before the env provider so they participate in the +// normal precedence chain. +func Load(opts Options) (*Config, error) { + home := opts.Home + if home == "" { + // Resolve once; used for tilde expansion and config file path. + if opts.Env != nil { + home = opts.Env("HOME") + } + } + getenv := opts.Env + if getenv == nil { + getenv = realGetenv + } + + k := koanf.New(".") + + // 1. Defaults — lowest precedence. + if err := k.Load(structs.Provider(defaults(), "koanf"), nil); err != nil { + return nil, fmt.Errorf("config: load defaults: %w", err) + } + + // 2. Config file — optional; missing is not an error. + configPath := expandTilde("~/.config/failsafe/config.yaml", home) + if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil { + // A missing file is the common/expected case; any read/parse error on + // an existing file is surfaced so the operator knows config is broken. + if !isNotExist(err) { + return nil, fmt.Errorf("config: load file %s: %w", configPath, err) + } + } + + // 3. Env provider — apply back-compat shims first, then load standard vars. + shimMap := buildEnvShims(getenv) + + if err := k.Load(shimEnvProvider{m: shimMap}, nil); err != nil { + return nil, fmt.Errorf("config: apply env shims: %w", err) + } + + // Use an injectable env provider so tests can supply a custom env function. + // In production getenv == os.Getenv; in tests it's the opts.Env stub. + if err := k.Load(newEnvProvider("FAILSAFE_", getenv), nil); err != nil { + return nil, fmt.Errorf("config: load env: %w", err) + } + + // 4. Flags — highest precedence (skipped when nil). + if opts.Flags != nil { + if err := k.Load(posflag.Provider(opts.Flags, ".", k), nil); err != nil { + return nil, fmt.Errorf("config: load flags: %w", err) + } + } + + // Unmarshal into typed struct. + var cfg Config + if err := k.Unmarshal("", &cfg); err != nil { + return nil, fmt.Errorf("config: unmarshal: %w", err) + } + + // Expand tilde and ${VAR} in path-type fields. + expandPaths(&cfg, home, getenv) + + // Validate safety invariants — fatal on violation. + if err := cfg.Validate(); err != nil { + return nil, err + } + + return &cfg, nil +} + +// --------------------------------------------------------------------------- +// Env transform (FAILSAFE_ → koanf keys) +// --------------------------------------------------------------------------- + +// envKeyTable is a static mapping from each supported FAILSAFE_* env var name +// to its exact koanf dot-path key. Using a lookup table instead of a +// mechanical _→. replacement avoids mangling multi-word field names such as +// FAILSAFE_MODE_PANE_DIR (which must map to "mode.pane_dir", not +// "mode.pane.dir"). +// +// Exclusions (intentional): +// - FAILSAFE_MODE: handled by the mode chain's EnvSource, not a config field. +// - FAILSAFE_LOG: handled by the back-compat shim (buildEnvShims). +var envKeyTable = map[string]string{ + "FAILSAFE_MODE_PANE_DIR": "mode.pane_dir", + "FAILSAFE_LOG_ENABLED": "log.enabled", + "FAILSAFE_LOG_PATH": "log.path", + "FAILSAFE_LOG_REDACT": "log.redact", + "FAILSAFE_TELEMETRY_ENABLED": "telemetry.enabled", + "FAILSAFE_TELEMETRY_OTLP_ENDPOINT": "telemetry.otlp_endpoint", + "FAILSAFE_POLICY_USER_PATH": "policy.user_path", + "FAILSAFE_POLICY_TOOLS_DIR": "policy.tools_dir", + "FAILSAFE_TRUST_PATH": "trust.path", +} + +// envTransform converts a FAILSAFE_* env key to the matching koanf key using +// the static envKeyTable. Returns "" for unrecognised keys so the caller can +// skip them cleanly. +func envTransform(s string) string { + return envKeyTable[s] +} + +// --------------------------------------------------------------------------- +// Back-compat shim helpers +// --------------------------------------------------------------------------- + +// buildEnvShims inspects the legacy FAILSAFE_LOG env var and returns a flat +// koanf key map that reproduces the exact semantics of auditlog.DefaultLogger: +// +// FAILSAFE_LOG=off → log.enabled=false +// FAILSAFE_LOG= → log.path=, log.enabled=true +// FAILSAFE_LOG unset/"" → no override (defaults stand) +// +// FAILSAFE_MODE is deliberately excluded: it lives as EnvSource{Name:"FAILSAFE_MODE"} +// in the mode chain so pane-file sources can override it. +func buildEnvShims(getenv func(string) string) map[string]interface{} { + m := map[string]interface{}{} + switch v := getenv("FAILSAFE_LOG"); { + case v == "off": + m["log.enabled"] = false + case v != "": + m["log.path"] = v + m["log.enabled"] = true + } + return m +} + +// shimEnvProvider is a minimal koanf.Provider that injects the pre-computed +// shim map (flat keys with "."-delimiter) into the koanf instance. +type shimEnvProvider struct{ m map[string]interface{} } + +func (s shimEnvProvider) Read() (map[string]interface{}, error) { + // Unflatten "log.enabled" → {"log": {"enabled": v}}. + out := map[string]interface{}{} + for k, v := range s.m { + parts := strings.Split(k, ".") + setNested(out, parts, v) + } + return out, nil +} +func (s shimEnvProvider) ReadBytes() ([]byte, error) { + return nil, errors.New("shimEnvProvider does not support ReadBytes") +} + +// setNested builds a nested map from a key path and value. +func setNested(m map[string]interface{}, path []string, v interface{}) { + if len(path) == 1 { + m[path[0]] = v + return + } + sub, ok := m[path[0]].(map[string]interface{}) + if !ok { + sub = map[string]interface{}{} + m[path[0]] = sub + } + setNested(sub, path[1:], v) +} + +// --------------------------------------------------------------------------- +// Injectable env provider +// --------------------------------------------------------------------------- + +// injectableEnvProvider is a koanf.Provider that reads FAILSAFE_* vars from a +// caller-supplied getenv function instead of os.Environ. This makes the env +// layer fully testable without real environment variables. +type injectableEnvProvider struct { + prefix string + getenv func(string) string + environ func() []string // returns "KEY=VALUE" pairs; nil → os.Environ +} + +// newEnvProvider returns an env provider that uses getenv for lookups. +// In production getenv should be os.Getenv; in tests supply a stub. +func newEnvProvider(prefix string, getenv func(string) string) *injectableEnvProvider { + return &injectableEnvProvider{ + prefix: prefix, + getenv: getenv, + environ: realEnviron, + } +} + +func (e *injectableEnvProvider) ReadBytes() ([]byte, error) { + return nil, errors.New("injectableEnvProvider does not support ReadBytes") +} + +func (e *injectableEnvProvider) Read() (map[string]interface{}, error) { + // Collect all env keys to check against our prefix. We need both the key + // list (from environ) and the values (from our injected getenv) so tests + // that inject a stub getenv get the correct values. + out := map[string]interface{}{} + for _, kv := range e.environ() { + idx := strings.IndexByte(kv, '=') + if idx < 0 { + continue + } + rawKey := kv[:idx] + if !strings.HasPrefix(rawKey, e.prefix) { + continue + } + mapped := envTransform(rawKey) + if mapped == "" { + continue + } + // Skip single-segment keys (e.g. FAILSAFE_MODE → "mode"): they map to + // struct fields, not leaf values. FAILSAFE_MODE is intentionally handled + // via the mode chain's EnvSource, not the config struct. + if !strings.Contains(mapped, ".") { + continue + } + val := e.getenv(rawKey) + parts := strings.Split(mapped, ".") + setNested(out, parts, val) + } + // Also check any keys that opts.Env knows about but os.Environ might not + // (the injected env may be a pure stub with no real os.Environ backing). + // We scan a known set of FAILSAFE_ keys so we don't miss stub-only vars. + for _, rawKey := range knownEnvKeysList() { + if !strings.HasPrefix(rawKey, e.prefix) { + continue + } + val := e.getenv(rawKey) + if val == "" { + continue + } + mapped := envTransform(rawKey) + if mapped == "" { + continue + } + parts := strings.Split(mapped, ".") + setNested(out, parts, val) + } + return out, nil +} + +// knownEnvKeys is the canonical list of FAILSAFE_* env var names supported in +// v1. Derived from envKeyTable so it stays in sync automatically. +// Used by injectableEnvProvider to ensure stub envs (used in tests) are fully +// read even when os.Environ does not contain these keys. +func knownEnvKeysList() []string { + keys := make([]string, 0, len(envKeyTable)) + for k := range envKeyTable { + keys = append(keys, k) + } + return keys +} + +// --------------------------------------------------------------------------- +// Path expansion +// --------------------------------------------------------------------------- + +// expandPaths expands tilde and ${VAR} in all path-type config fields. +// It uses the same substitution semantics as internal/mode.expandVars but is +// applied post-unmarshal so struct types are already known. +func expandPaths(cfg *Config, home string, getenv func(string) string) { + envMap := func(k string) string { return getenv(k) } + expand := func(s string) string { + s = expandTilde(s, home) + s = expandEnvVars(s, envMap) + return s + } + cfg.Mode.PaneDir = expand(cfg.Mode.PaneDir) + cfg.Log.Path = expand(cfg.Log.Path) + cfg.Policy.UserPath = expand(cfg.Policy.UserPath) + cfg.Policy.ToolsDir = expand(cfg.Policy.ToolsDir) + cfg.Trust.Path = expand(cfg.Trust.Path) + cfg.Telemetry.OTLPEndpoint = expand(cfg.Telemetry.OTLPEndpoint) + cfg.ControlPlane.URL = expand(cfg.ControlPlane.URL) +} + +// expandTilde replaces a leading ~ with home. +func expandTilde(s, home string) string { + if s == "~" { + return home + } + if strings.HasPrefix(s, "~/") { + return home + s[1:] + } + return s +} + +// expandEnvVars performs ${VAR} substitution (no substitution if VAR is unset). +// A missing variable leaves the literal "${VAR}" in place rather than returning +// an error — callers will surface a "path is empty" error at use-time, which is +// clearer than a generic expansion error at load-time. +func expandEnvVars(s string, getenv func(string) string) string { + var out strings.Builder + i := 0 + for i < len(s) { + if i+1 < len(s) && s[i] == '$' && s[i+1] == '{' { + end := strings.Index(s[i+2:], "}") + if end == -1 { + out.WriteString(s[i:]) + break + } + name := s[i+2 : i+2+end] + v := getenv(name) + if v != "" { + out.WriteString(v) + } else { + out.WriteString(s[i : i+2+end+1]) // preserve literal + } + i += 2 + end + 1 + continue + } + out.WriteByte(s[i]) + i++ + } + return out.String() +} + +// --------------------------------------------------------------------------- +// Validate +// --------------------------------------------------------------------------- + +// Validate enforces safety invariants. It is called by Load after Unmarshal +// and path expansion. A validation error is fatal. +func (c *Config) Validate() error { + // log.path must be non-empty after expansion. + if c.Log.Path == "" { + return errors.New("config: log.path is empty after expansion") + } + + // mode.pane_dir must be non-empty after expansion. + if c.Mode.PaneDir == "" { + return errors.New("config: mode.pane_dir is empty after expansion") + } + + // log.redact is safety-fixed true; disabling redaction is a fatal error. + if !c.Log.Redact { + return errors.New("config: log.redact cannot be disabled (safety-fixed true)") + } + + // control_plane.* is reserved in v1; either field set → fatal. + if c.ControlPlane.URL != "" || c.ControlPlane.BundleSigningKey != "" { + return errors.New("config: control_plane.* is reserved and not supported in v1") + } + + return nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// realGetenv is the production env accessor. +// Wrapped so tests can substitute without build tags. +func realGetenv(key string) string { + // Import avoided: use os through the type assertion below. + // The actual import is in config_os.go to keep this file testable. + return _osGetenv(key) +} + +// isNotExist returns true when the error represents a missing file. koanf's +// file provider wraps os.ReadFile errors, so we check the underlying cause. +func isNotExist(err error) bool { + if err == nil { + return false + } + // Check for "no such file or directory" in the error message as a fallback, + // since koanf wraps the underlying os.PathError differently across versions. + msg := err.Error() + return strings.Contains(msg, "no such file") || + strings.Contains(msg, "not exist") || + strings.Contains(msg, "cannot find") +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..1439570 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,433 @@ +// Copyright 2026 Undermountain Coding Company +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/spf13/pflag" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// noEnv returns "" for every key — a clean environment for most tests. +func noEnv(string) string { return "" } + +// homeEnv returns an env func that injects home for "HOME" and delegates the +// rest to extra (which may be nil → noEnv). +func homeEnv(home string, extra map[string]string) func(string) string { + return func(k string) string { + if k == "HOME" { + return home + } + if extra != nil { + return extra[k] + } + return "" + } +} + +// mustLoad calls Load and fatals on error — used when a test expects success. +func mustLoad(t *testing.T, opts Options) *Config { + t.Helper() + cfg, err := Load(opts) + if err != nil { + t.Fatalf("Load: unexpected error: %v", err) + } + return cfg +} + +// --------------------------------------------------------------------------- +// Missing file → all defaults +// --------------------------------------------------------------------------- + +// TestMissingFileReturnsDefaults ensures that when no config.yaml exists, Load +// returns a fully-populated Config equal to the documented defaults. +func TestMissingFileReturnsDefaults(t *testing.T) { + home := t.TempDir() + cfg := mustLoad(t, Options{ + Home: home, + Env: homeEnv(home, nil), + }) + + // Mode defaults. + if cfg.Mode.PaneDir != home+"/.claude/pane-mode" { + t.Errorf("Mode.PaneDir: want %s/.claude/pane-mode, got %q", home, cfg.Mode.PaneDir) + } + + // Log defaults. + if !cfg.Log.Enabled { + t.Error("Log.Enabled: want true, got false") + } + want := home + "/.config/failsafe/decisions.jsonl" + if cfg.Log.Path != want { + t.Errorf("Log.Path: want %q, got %q", want, cfg.Log.Path) + } + if !cfg.Log.Redact { + t.Error("Log.Redact: want true, got false") + } + + // Telemetry defaults (off by default). + if cfg.Telemetry.Enabled { + t.Error("Telemetry.Enabled: want false, got true") + } + if cfg.Telemetry.OTLPEndpoint != "" { + t.Errorf("Telemetry.OTLPEndpoint: want empty, got %q", cfg.Telemetry.OTLPEndpoint) + } + + // Policy defaults. + if cfg.Policy.UserPath != home+"/.config/failsafe/policy.rego" { + t.Errorf("Policy.UserPath: got %q", cfg.Policy.UserPath) + } + if cfg.Policy.ToolsDir != home+"/.config/failsafe/tools" { + t.Errorf("Policy.ToolsDir: got %q", cfg.Policy.ToolsDir) + } + + // Trust default. + if cfg.Trust.Path != home+"/.config/failsafe/trusted-repos.yaml" { + t.Errorf("Trust.Path: got %q", cfg.Trust.Path) + } +} + +// --------------------------------------------------------------------------- +// Precedence: flags > env > file > defaults +// --------------------------------------------------------------------------- + +// TestPrecedence writes a config.yaml that sets log.path, then an env var that +// overrides it further, then a flag that wins over everything. Each layer is +// checked to confirm the strict ordering. +func TestPrecedence(t *testing.T) { + home := t.TempDir() + + // Create config directory and file. + cfgDir := filepath.Join(home, ".config", "failsafe") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + fileVal := filepath.Join(home, "file-log.jsonl") + envVal := filepath.Join(home, "env-log.jsonl") + flagVal := filepath.Join(home, "flag-log.jsonl") + + yaml := fmt.Sprintf("log:\n path: %s\n", fileVal) + if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + + // Env-only: file wins over defaults. + cfgFileOnly := mustLoad(t, Options{ + Home: home, + Env: homeEnv(home, nil), + }) + if cfgFileOnly.Log.Path != fileVal { + t.Errorf("file layer: want %q, got %q", fileVal, cfgFileOnly.Log.Path) + } + + // Env wins over file. + cfgEnv := mustLoad(t, Options{ + Home: home, + Env: homeEnv(home, map[string]string{ + "FAILSAFE_LOG_PATH": envVal, + }), + }) + if cfgEnv.Log.Path != envVal { + t.Errorf("env layer: want %q, got %q", envVal, cfgEnv.Log.Path) + } + + // Flag wins over env. + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.String("log.path", "", "log path") + if err := f.Parse([]string{"--log.path=" + flagVal}); err != nil { + t.Fatal(err) + } + cfgFlag := mustLoad(t, Options{ + Home: home, + Env: homeEnv(home, map[string]string{"FAILSAFE_LOG_PATH": envVal}), + Flags: f, + }) + if cfgFlag.Log.Path != flagVal { + t.Errorf("flag layer: want %q, got %q", flagVal, cfgFlag.Log.Path) + } +} + +// --------------------------------------------------------------------------- +// Back-compat shims for FAILSAFE_LOG +// --------------------------------------------------------------------------- + +// TestFAILSAFELogOff reproduces auditlog.DefaultLogger semantics: +// FAILSAFE_LOG=off → log.enabled=false, path stays at default. +func TestFAILSAFELogOff(t *testing.T) { + home := t.TempDir() + cfg := mustLoad(t, Options{ + Home: home, + Env: homeEnv(home, map[string]string{"FAILSAFE_LOG": "off"}), + }) + if cfg.Log.Enabled { + t.Error("FAILSAFE_LOG=off: Log.Enabled should be false") + } + // Path should still be the default (not empty); DefaultLogger leaves it + // empty when disabled, but our Config retains the default path — this is + // intentional (the path is just unused when Enabled=false). +} + +// TestFAILSAFELogPath reproduces: FAILSAFE_LOG= → path=, enabled=true. +func TestFAILSAFELogPath(t *testing.T) { + home := t.TempDir() + customPath := "/x/y.jsonl" + cfg := mustLoad(t, Options{ + Home: home, + Env: homeEnv(home, map[string]string{"FAILSAFE_LOG": customPath}), + }) + if !cfg.Log.Enabled { + t.Error("FAILSAFE_LOG=: Log.Enabled should be true") + } + if cfg.Log.Path != customPath { + t.Errorf("FAILSAFE_LOG=: Log.Path want %q, got %q", customPath, cfg.Log.Path) + } +} + +// TestFAILSAFELogShimMatchesDefaultLogger confirms that the shim reproduces the +// exact same decisions that auditlog.DefaultLogger makes for the same env value. +// This is the "byte-identical back-compat" guarantee from the spec §Migration. +func TestFAILSAFELogShimMatchesDefaultLogger(t *testing.T) { + home := t.TempDir() + + cases := []struct { + envVal string + wantEnabled bool + wantPath string // "" means "the home-derived default" + }{ + {"off", false, ""}, + {"/x/y.jsonl", true, "/x/y.jsonl"}, + {"", true, filepath.Join(home, ".config", "failsafe", "decisions.jsonl")}, + } + + for _, tc := range cases { + t.Run("FAILSAFE_LOG="+tc.envVal, func(t *testing.T) { + cfg := mustLoad(t, Options{ + Home: home, + Env: homeEnv(home, map[string]string{"FAILSAFE_LOG": tc.envVal}), + }) + + if cfg.Log.Enabled != tc.wantEnabled { + t.Errorf("Enabled: want %v, got %v", tc.wantEnabled, cfg.Log.Enabled) + } + if tc.wantPath != "" && cfg.Log.Path != tc.wantPath { + t.Errorf("Path: want %q, got %q", tc.wantPath, cfg.Log.Path) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Safety-fixed: log.redact=false rejected +// --------------------------------------------------------------------------- + +func TestLogRedactFalseRejected(t *testing.T) { + home := t.TempDir() + cfgDir := filepath.Join(home, ".config", "failsafe") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + yaml := "log:\n redact: false\n" + if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + + _, err := Load(Options{Home: home, Env: homeEnv(home, nil)}) + if err == nil { + t.Fatal("expected error for log.redact=false, got nil") + } + if !contains(err.Error(), "redact") { + t.Errorf("expected error to mention 'redact', got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Safety-fixed: control_plane.* rejected in v1 +// --------------------------------------------------------------------------- + +func TestControlPlaneRejected(t *testing.T) { + home := t.TempDir() + cfgDir := filepath.Join(home, ".config", "failsafe") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + yaml := "control_plane:\n url: https://example.com\n" + if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + + _, err := Load(Options{Home: home, Env: homeEnv(home, nil)}) + if err == nil { + t.Fatal("expected error for control_plane.url set, got nil") + } + if !contains(err.Error(), "control_plane") { + t.Errorf("expected error to mention 'control_plane', got: %v", err) + } +} + +func TestControlPlaneBundleSigningKeyRejected(t *testing.T) { + home := t.TempDir() + cfgDir := filepath.Join(home, ".config", "failsafe") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + yaml := "control_plane:\n bundle_signing_key: abc123\n" + if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + + _, err := Load(Options{Home: home, Env: homeEnv(home, nil)}) + if err == nil { + t.Fatal("expected error for control_plane.bundle_signing_key set, got nil") + } + if !contains(err.Error(), "control_plane") { + t.Errorf("expected error to mention 'control_plane', got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Tilde expansion +// --------------------------------------------------------------------------- + +func TestTildeExpansion(t *testing.T) { + home := "/custom/home" + cfg := mustLoad(t, Options{ + Home: home, + Env: homeEnv(home, nil), + }) + + checks := []struct { + name string + got string + want string + }{ + {"Mode.PaneDir", cfg.Mode.PaneDir, home + "/.claude/pane-mode"}, + {"Log.Path", cfg.Log.Path, home + "/.config/failsafe/decisions.jsonl"}, + {"Policy.UserPath", cfg.Policy.UserPath, home + "/.config/failsafe/policy.rego"}, + {"Policy.ToolsDir", cfg.Policy.ToolsDir, home + "/.config/failsafe/tools"}, + {"Trust.Path", cfg.Trust.Path, home + "/.config/failsafe/trusted-repos.yaml"}, + } + for _, c := range checks { + if c.got != c.want { + t.Errorf("%s: want %q, got %q", c.name, c.want, c.got) + } + } +} + +// TestTildeExpansionFromFile ensures tilde in a config.yaml path uses opts.Home. +func TestTildeExpansionFromFile(t *testing.T) { + home := t.TempDir() + cfgDir := filepath.Join(home, ".config", "failsafe") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + // Write a config.yaml with a tilde path. + yaml := "log:\n path: ~/custom-decisions.jsonl\n" + if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + + cfg := mustLoad(t, Options{Home: home, Env: homeEnv(home, nil)}) + want := home + "/custom-decisions.jsonl" + if cfg.Log.Path != want { + t.Errorf("tilde in config file: want %q, got %q", want, cfg.Log.Path) + } +} + +// --------------------------------------------------------------------------- +// Regression: multi-word env var keys map to correct koanf fields +// --------------------------------------------------------------------------- + +// TestEnvMultiWordKeysMapCorrectly verifies that env vars whose names contain +// multiple underscores after the FAILSAFE_ prefix (e.g. FAILSAFE_MODE_PANE_DIR) +// land in the correct Config field. Before the static-table fix these vars +// were silently ignored because the mechanical _→. transform produced invalid +// koanf keys (e.g. "mode.pane.dir" instead of "mode.pane_dir"). +func TestEnvMultiWordKeysMapCorrectly(t *testing.T) { + home := t.TempDir() + + env := homeEnv(home, map[string]string{ + "FAILSAFE_MODE_PANE_DIR": "/custom/pane-mode", + "FAILSAFE_POLICY_USER_PATH": "/custom/policy.rego", + "FAILSAFE_POLICY_TOOLS_DIR": "/custom/tools", + "FAILSAFE_TELEMETRY_OTLP_ENDPOINT": "http://otel.example.com:4318", + }) + + cfg := mustLoad(t, Options{Home: home, Env: env}) + + if cfg.Mode.PaneDir != "/custom/pane-mode" { + t.Errorf("FAILSAFE_MODE_PANE_DIR: want /custom/pane-mode, got %q", cfg.Mode.PaneDir) + } + if cfg.Policy.UserPath != "/custom/policy.rego" { + t.Errorf("FAILSAFE_POLICY_USER_PATH: want /custom/policy.rego, got %q", cfg.Policy.UserPath) + } + if cfg.Policy.ToolsDir != "/custom/tools" { + t.Errorf("FAILSAFE_POLICY_TOOLS_DIR: want /custom/tools, got %q", cfg.Policy.ToolsDir) + } + if cfg.Telemetry.OTLPEndpoint != "http://otel.example.com:4318" { + t.Errorf("FAILSAFE_TELEMETRY_OTLP_ENDPOINT: want http://otel.example.com:4318, got %q", cfg.Telemetry.OTLPEndpoint) + } +} + +// TestFAILSAFEModeNotLeakedToConfig asserts that FAILSAFE_MODE is intentionally +// excluded from the config key table and does NOT populate any Config field. +// It lives as EnvSource{Name:"FAILSAFE_MODE"} in the mode chain only. +func TestFAILSAFEModeNotLeakedToConfig(t *testing.T) { + home := t.TempDir() + + env := homeEnv(home, map[string]string{ + "FAILSAFE_MODE": "disabled", + }) + + cfg := mustLoad(t, Options{Home: home, Env: env}) + + // FAILSAFE_MODE must not have set any Config field. The only observable + // effect would be a non-default Mode.PaneDir or a new field under Mode — we + // check the pane_dir default is undisturbed and that no string field in the + // entire Config unexpectedly holds the value "disabled". + defaultPaneDir := home + "/.claude/pane-mode" + if cfg.Mode.PaneDir != defaultPaneDir { + t.Errorf("FAILSAFE_MODE leaked into Mode.PaneDir: got %q", cfg.Mode.PaneDir) + } + + // Spot-check: "disabled" must not appear in any string field. + stringFields := []struct { + name string + val string + }{ + {"Mode.PaneDir", cfg.Mode.PaneDir}, + {"Log.Path", cfg.Log.Path}, + {"Policy.UserPath", cfg.Policy.UserPath}, + {"Policy.ToolsDir", cfg.Policy.ToolsDir}, + {"Trust.Path", cfg.Trust.Path}, + {"Telemetry.OTLPEndpoint", cfg.Telemetry.OTLPEndpoint}, + } + for _, f := range stringFields { + if f.val == "disabled" { + t.Errorf("FAILSAFE_MODE leaked into Config.%s = %q", f.name, f.val) + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || + func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + }()) +} diff --git a/internal/config/os.go b/internal/config/os.go new file mode 100644 index 0000000..938e8d1 --- /dev/null +++ b/internal/config/os.go @@ -0,0 +1,15 @@ +// Copyright 2026 Undermountain Coding Company +// SPDX-License-Identifier: Apache-2.0 + +package config + +import "os" + +// _osGetenv is the real os.Getenv call, referenced by realGetenv in config.go. +// Keeping it in a separate file makes the dependency on os explicit and the +// rest of config.go fully testable with injected env functions. +var _osGetenv = os.Getenv + +// realEnviron returns os.Environ() as the default environ source for the +// injectable env provider. +func realEnviron() []string { return os.Environ() } diff --git a/internal/policy/chain.go b/internal/policy/chain.go index f8ac7a3..4f6a086 100644 --- a/internal/policy/chain.go +++ b/internal/policy/chain.go @@ -20,6 +20,11 @@ type DiscoverOpts struct { Home string CWD string + // UserPolicyPath overrides the default user policy path + // ($HOME/.config/failsafe/policy.rego). When non-empty, this path is used + // instead of deriving it from Home. Path must be fully expanded (no tildes). + UserPolicyPath string + // IsTrusted reports whether a repo path (the directory containing the // .failsafe.rego) has been explicitly trusted via `failsafe trust`. // Nil → all repos are untrusted (safe default). @@ -43,8 +48,11 @@ func Discover(opts DiscoverOpts) ([]Module, error) { out = append(out, mods...) } - if opts.Home != "" { - userPath := filepath.Join(opts.Home, ".config", "failsafe", "policy.rego") + if opts.UserPolicyPath != "" || opts.Home != "" { + userPath := opts.UserPolicyPath + if userPath == "" { + userPath = filepath.Join(opts.Home, ".config", "failsafe", "policy.rego") + } body, err := os.ReadFile(userPath) switch { case err == nil: diff --git a/internal/subcommand/hook.go b/internal/subcommand/hook.go index a73350f..e44143a 100644 --- a/internal/subcommand/hook.go +++ b/internal/subcommand/hook.go @@ -18,6 +18,7 @@ import ( "time" "github.com/UndermountainCC/failsafe/internal/auditlog" + "github.com/UndermountainCC/failsafe/internal/config" embedfs "github.com/UndermountainCC/failsafe/internal/embed" "github.com/UndermountainCC/failsafe/internal/enrich" "github.com/UndermountainCC/failsafe/internal/facts" @@ -37,9 +38,14 @@ type HookOptions struct { Now time.Time BundledLoad func() ([]policy.Module, error) // Logger receives one record per infra-tool decision (and per - // refuse/parse block). Nil → resolved from home + FAILSAFE_LOG via - // auditlog.DefaultLogger. Logging is best-effort and never fails the hook. + // refuse/parse block). Nil → resolved from cfg.Log (or home + FAILSAFE_LOG + // when Cfg is nil). Logging is best-effort and never fails the hook. Logger *auditlog.Logger + // Cfg is the loaded configuration. Nil → defaults are loaded via + // config.Load so there is a single code path regardless of caller. + // When non-nil, Cfg drives the mode chain, audit logger, policy paths, + // tools dir, and trust path instead of hardcoded literals. + Cfg *config.Config } // Hook is the default subcommand: read Claude Code hook JSON, decide, emit. @@ -58,12 +64,24 @@ func Hook(stdin io.Reader, stdout, stderr io.Writer, opts HookOptions) int { home = os.Getenv("HOME") } + // Resolve config: use caller-supplied Cfg or load defaults (no config file + // → all defaults = today's hardcoded values; fail-closed on a bad file). + cfg := opts.Cfg + if cfg == nil { + loaded, err := config.Load(config.Options{Home: home, Env: os.Getenv}) + if err != nil { + fmt.Fprintf(stderr, "failsafe: load config: %v\n", err) + return 1 + } + cfg = loaded + } + // 1. Resolve mode. modeVal := opts.ModeOverride if modeVal == "" { chain := opts.ModeChain if chain == nil { - chain = defaultModeChain() + chain = buildModeChain(cfg, home) } val, _, err := chain.Resolve(envWithHome(home)) if err != nil { @@ -78,7 +96,7 @@ func Hook(stdin io.Reader, stdout, stderr io.Writer, opts HookOptions) int { // Logging never fails the hook — logRec ignores Log's error. lg := opts.Logger if lg == nil { - lg = auditlog.DefaultLogger(home, os.Getenv) + lg = loggerFromConfig(cfg) } now := opts.Now if now.IsZero() { @@ -116,7 +134,7 @@ func Hook(stdin io.Reader, stdout, stderr io.Writer, opts HookOptions) int { } // 3. Set up shared inputs: trust file, bundled-policy loader, registry. - tr, err := trust.Load(home) + tr, err := trust.LoadFromPath(cfg.Trust.Path) if err != nil { fmt.Fprintf(stderr, "failsafe: trust load: %v\n", err) return 1 @@ -125,7 +143,7 @@ func Hook(stdin io.Reader, stdout, stderr io.Writer, opts HookOptions) int { if bundledLoad == nil { bundledLoad = loadBundledPolicies } - reg, err := buildRegistry(home) + reg, err := buildRegistry(cfg.Policy.ToolsDir) if err != nil { // Fail-closed: a corrupt bundled tool YAML or a malformed user // tool YAML (which would silently bypass policy for that tool's @@ -145,10 +163,11 @@ func Hook(stdin io.Reader, stdout, stderr io.Writer, opts HookOptions) int { return e, nil } mods, err := policy.Discover(policy.DiscoverOpts{ - BundledLoader: bundledLoad, - Home: home, - CWD: cwd, - IsTrusted: tr.IsTrusted, + BundledLoader: bundledLoad, + Home: home, + CWD: cwd, + UserPolicyPath: cfg.Policy.UserPath, + IsTrusted: tr.IsTrusted, }) if err != nil { return nil, fmt.Errorf("policy discovery: %w", err) @@ -245,8 +264,18 @@ func Hook(stdin io.Reader, stdout, stderr io.Writer, opts HookOptions) int { // DefaultModeChain returns the same mode chain used by the hook subcommand. // Exposed so the toggle and mode subcommands share the chain definition. +// It uses config defaults (identical to the previous hardcoded values). func DefaultModeChain() *mode.Chain { return defaultModeChain() } +// ModeChainFromConfig builds a mode.Chain from a loaded *config.Config. +// home is the resolved home directory (already expanded, no tilde). +// Callers that already hold a *config.Config should prefer this over +// DefaultModeChain so that Mode.PaneDir from config is used. +// The chain Default is always the hardcoded literal "enabled" (not configurable). +func ModeChainFromConfig(cfg *config.Config, home string) *mode.Chain { + return buildModeChain(cfg, home) +} + // EnvFromOS returns os.Environ() as a map. func EnvFromOS() map[string]string { out := map[string]string{} @@ -258,25 +287,78 @@ func EnvFromOS() map[string]string { return out } +// defaultModeChain returns the chain built from compile-time defaults. +// Kept for DefaultModeChain() and the toggle/mode subcommands that call it +// without a config. func defaultModeChain() *mode.Chain { + cfg, _ := config.Load(config.Options{Home: os.Getenv("HOME"), Env: os.Getenv}) + if cfg == nil { + // Absolute fallback: should never happen (Load only fails on a bad + // config file, and an absent file is fine). Return the hardcoded chain. + return &mode.Chain{ + Sources: []mode.Source{ + mode.EnvSource{Name: "FAILSAFE_MODE"}, + mode.FileSource{Pattern: "${HOME}/.claude/pane-mode/${WEZTERM_PANE}"}, + mode.FileSource{Pattern: "${HOME}/.claude/pane-mode/${TMUX_PANE}"}, + mode.FileSource{Pattern: "${HOME}/.claude/pane-mode/${ITERM_SESSION_ID}"}, + mode.FileSource{Pattern: "${HOME}/.claude/pane-mode/${KITTY_WINDOW_ID}"}, + mode.FileSource{Pattern: "${HOME}/.claude/pane-mode/${CLAUDE_SESSION_ID}"}, + mode.TTYSource{Dir: "${HOME}/.config/failsafe"}, + mode.FileSource{Pattern: "${HOME}/.config/failsafe/mode"}, + }, + Default: "enabled", + } + } + return buildModeChain(cfg, os.Getenv("HOME")) +} + +// buildModeChain builds the mode.Chain from a loaded *config.Config. +// home is the resolved home directory (already expanded, no tilde). +// The source ORDER and kinds are fixed in code; only cfg.Mode.PaneDir is +// driven by config (spec §5 "recorded not driven"). +// The chain Default is always hardcoded to "enabled" — it is never configurable, +// to prevent a self-disable vector via config.yaml. +func buildModeChain(cfg *config.Config, home string) *mode.Chain { + paneDir := cfg.Mode.PaneDir + // If PaneDir is already an absolute path (expanded by config.Load), use it + // directly; otherwise fall back to the ${HOME}-prefixed pattern form so the + // mode chain's own ${VAR} expansion still works. + paneDirPattern := func(varName string) string { + // config.Load expands tildes; paneDir is already absolute. + // We still want ${WEZTERM_PANE} etc. resolved by the chain at + // resolve-time, so append the variable placeholder. + return paneDir + "/${" + varName + "}" + } return &mode.Chain{ Sources: []mode.Source{ mode.EnvSource{Name: "FAILSAFE_MODE"}, - mode.FileSource{Pattern: "${HOME}/.claude/pane-mode/${WEZTERM_PANE}"}, - mode.FileSource{Pattern: "${HOME}/.claude/pane-mode/${TMUX_PANE}"}, - mode.FileSource{Pattern: "${HOME}/.claude/pane-mode/${ITERM_SESSION_ID}"}, - mode.FileSource{Pattern: "${HOME}/.claude/pane-mode/${KITTY_WINDOW_ID}"}, - mode.FileSource{Pattern: "${HOME}/.claude/pane-mode/${CLAUDE_SESSION_ID}"}, + mode.FileSource{Pattern: paneDirPattern("WEZTERM_PANE")}, + mode.FileSource{Pattern: paneDirPattern("TMUX_PANE")}, + mode.FileSource{Pattern: paneDirPattern("ITERM_SESSION_ID")}, + mode.FileSource{Pattern: paneDirPattern("KITTY_WINDOW_ID")}, + mode.FileSource{Pattern: paneDirPattern("CLAUDE_SESSION_ID")}, // Per-controlling-tty: gives a plain shell (no multiplexer var) its // own writable mode instead of sharing the single global file. mode.TTYSource{Dir: "${HOME}/.config/failsafe"}, // Global last-resort fallback (always writable while HOME is set). mode.FileSource{Pattern: "${HOME}/.config/failsafe/mode"}, }, + // Default is always hardcoded; there is no mode.default config key. Default: "enabled", } } +// loggerFromConfig builds an *auditlog.Logger from cfg.Log. +// Mirrors the semantics of auditlog.DefaultLogger: +// - Disabled → empty Logger (no-op) +// - Enabled with path → file logger at that path +func loggerFromConfig(cfg *config.Config) *auditlog.Logger { + if !cfg.Log.Enabled { + return &auditlog.Logger{} + } + return &auditlog.Logger{Path: cfg.Log.Path} +} + func envWithHome(home string) map[string]string { out := map[string]string{} for _, kv := range os.Environ() { @@ -289,11 +371,12 @@ func envWithHome(home string) map[string]string { } // buildRegistry assembles the tool registry from Go-coded tools (kubectl, -// helm), bundled YAML tools, and user-provided YAML tools. Errors are -// fail-closed: a corrupt bundled YAML means the binary is broken, and a -// malformed user YAML means commands for that tool would silently bypass +// helm), bundled YAML tools, and user-provided YAML tools. toolsDir is the +// fully-expanded path to the user tools directory (e.g. from cfg.Policy.ToolsDir). +// Errors are fail-closed: a corrupt bundled YAML means the binary is broken, +// and a malformed user YAML means commands for that tool would silently bypass // policy. Both must surface to the caller, not be silently skipped. -func buildRegistry(home string) (*tools.Registry, error) { +func buildRegistry(toolsDir string) (*tools.Registry, error) { r := tools.NewRegistry() r.Add(tools.NewKubectl()) r.Add(tools.NewHelm()) @@ -309,15 +392,14 @@ func buildRegistry(home string) (*tools.Registry, error) { } r.Add(t) } - if home != "" { - userToolsDir := filepath.Join(home, ".config", "failsafe", "tools") - userTools := os.DirFS(userToolsDir) + if toolsDir != "" { + userTools := os.DirFS(toolsDir) entries, err := fs.ReadDir(userTools, ".") // fs.ReadDir on a nonexistent dir returns an error — that's the // common case (no user tools); tolerate it. Other errors propagate. if err != nil { if !os.IsNotExist(err) { - return nil, fmt.Errorf("read user tools dir %s: %w", userToolsDir, err) + return nil, fmt.Errorf("read user tools dir %s: %w", toolsDir, err) } return r, nil } diff --git a/internal/subcommand/modechain_test.go b/internal/subcommand/modechain_test.go index d82e3ae..1c2f1a0 100644 --- a/internal/subcommand/modechain_test.go +++ b/internal/subcommand/modechain_test.go @@ -4,8 +4,13 @@ package subcommand import ( + "bytes" + "os" + "path/filepath" + "strings" "testing" + "github.com/UndermountainCC/failsafe/internal/config" "github.com/UndermountainCC/failsafe/internal/mode" ) @@ -53,3 +58,87 @@ func TestResolvePaneID_SkipsGlobalFallback(t *testing.T) { t.Errorf("global fallback leaked as pane id %q; it must be skipped", got) } } + +// TestBuildModeChain_CustomPaneDir verifies that a HookOptions{Cfg: ...} with a +// custom Mode.PaneDir drives the mode file resolution under that directory, +// proving config actually wires through to the mode chain. +func TestBuildModeChain_CustomPaneDir(t *testing.T) { + // Set up a fake HOME with a custom pane dir. + dir := t.TempDir() + home := filepath.Join(dir, "home") + customPaneDir := filepath.Join(home, "custom-pane-mode") + if err := os.MkdirAll(customPaneDir, 0o755); err != nil { + t.Fatal(err) + } + + // Write "read" into the custom pane dir for pane "99". + if err := os.WriteFile(filepath.Join(customPaneDir, "99"), []byte("read"), 0o644); err != nil { + t.Fatal(err) + } + + // Build a config with the custom PaneDir. + cfg := &config.Config{ + Mode: config.ModeConfig{ + PaneDir: customPaneDir, + }, + Log: config.LogConfig{ + Enabled: false, + Path: filepath.Join(home, "decisions.jsonl"), + Redact: true, + }, + Policy: config.PolicyConfig{ + UserPath: filepath.Join(home, ".config", "failsafe", "policy.rego"), + ToolsDir: filepath.Join(home, ".config", "failsafe", "tools"), + }, + Trust: config.TrustConfig{ + Path: filepath.Join(home, ".config", "failsafe", "trusted-repos.yaml"), + }, + } + + // Verify buildModeChain produces a chain that resolves under customPaneDir. + chain := buildModeChain(cfg, home) + t.Setenv("WEZTERM_PANE", "99") + env := map[string]string{"HOME": home, "WEZTERM_PANE": "99"} + // "read" is the legacy vocabulary for "enabled"; Canonicalize maps it. + val, src, err := chain.Resolve(env) + if err != nil { + t.Fatalf("chain.Resolve: %v", err) + } + if val != "enabled" { + t.Errorf("expected canonicalized mode 'enabled' (from 'read' in pane file); got %q", val) + } + if src == nil { + t.Error("expected a source (custom pane file) to have resolved; got nil (default used)") + } + + // Confirm the chain patterns reference the custom pane dir, not the default. + for _, src := range chain.Sources { + if fs, ok := src.(mode.FileSource); ok { + if strings.Contains(fs.Pattern, ".claude/pane-mode") { + t.Errorf("chain should use custom pane dir %q but found default pattern %q", + customPaneDir, fs.Pattern) + } + } + } + + // Also verify that Hook uses the custom pane dir when Cfg is provided. + t.Setenv("FAILSAFE_MODE", "") + t.Setenv("TMUX_PANE", "") + t.Setenv("ITERM_SESSION_ID", "") + t.Setenv("KITTY_WINDOW_ID", "") + t.Setenv("CLAUDE_SESSION_ID", "") + + in := strings.NewReader(`{"tool_name":"Bash","tool_input":{"command":"kubectl apply -f foo.yaml"},"cwd":"/tmp","session_id":"sess-custom"}`) + var stdout, stderr bytes.Buffer + code := Hook(in, &stdout, &stderr, HookOptions{ + Home: home, + Cfg: cfg, + }) + if code != 0 { + t.Fatalf("hook exit=%d stderr=%s", code, stderr.String()) + } + // In "read" mode, kubectl apply should be blocked. + if !strings.Contains(stdout.String(), `"decision":"block"`) { + t.Errorf("expected block in read mode from custom pane dir; stdout=%s", stdout.String()) + } +} diff --git a/internal/subcommand/tools.go b/internal/subcommand/tools.go index ceb9974..e734f25 100644 --- a/internal/subcommand/tools.go +++ b/internal/subcommand/tools.go @@ -15,7 +15,8 @@ import ( ) type ToolsListOptions struct { - Home string + Home string + ToolsDir string // explicit tools directory; empty → derive from Home } func ToolsList(out io.Writer, opts ToolsListOptions) int { @@ -27,8 +28,11 @@ func ToolsList(out io.Writer, opts ToolsListOptions) int { for _, n := range embedfs.BundledToolNames() { rows = append(rows, row{strings.TrimSuffix(n, ".yaml"), "bundled YAML"}) } - if opts.Home != "" { - userDir := filepath.Join(opts.Home, ".config", "failsafe", "tools") + userDir := opts.ToolsDir + if userDir == "" && opts.Home != "" { + userDir = filepath.Join(opts.Home, ".config", "failsafe", "tools") + } + if userDir != "" { entries, _ := fs.ReadDir(os.DirFS(userDir), ".") for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { diff --git a/internal/subcommand/tools_test.go b/internal/subcommand/tools_test.go index 7e8e954..bd68157 100644 --- a/internal/subcommand/tools_test.go +++ b/internal/subcommand/tools_test.go @@ -5,6 +5,8 @@ package subcommand import ( "bytes" + "os" + "path/filepath" "strings" "testing" ) @@ -27,3 +29,40 @@ func TestToolsList_ShowsAll(t *testing.T) { t.Error("missing 'bundled YAML' tag") } } + +// TestToolsList_CustomToolsDir proves that ToolsListOptions.ToolsDir (sourced +// from cfg.Policy.ToolsDir in main.go) is used instead of the Home-derived +// default. A YAML file placed in the custom directory must appear in the output; +// a file placed in the default Home-derived directory must NOT appear. +func TestToolsList_CustomToolsDir(t *testing.T) { + dir := t.TempDir() + home := filepath.Join(dir, "home") + customToolsDir := filepath.Join(dir, "custom-tools") + if err := os.MkdirAll(customToolsDir, 0o755); err != nil { + t.Fatal(err) + } + // Write a fake tool YAML into the custom dir. + if err := os.WriteFile(filepath.Join(customToolsDir, "mytool.yaml"), []byte("name: mytool\n"), 0o644); err != nil { + t.Fatal(err) + } + // Write a fake tool YAML into the home-derived default dir (should NOT appear). + defaultToolsDir := filepath.Join(home, ".config", "failsafe", "tools") + if err := os.MkdirAll(defaultToolsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(defaultToolsDir, "defaulttool.yaml"), []byte("name: defaulttool\n"), 0o644); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + code := ToolsList(&out, ToolsListOptions{Home: home, ToolsDir: customToolsDir}) + if code != 0 { + t.Fatalf("exit=%d", code) + } + if !strings.Contains(out.String(), "mytool") { + t.Errorf("custom ToolsDir tool 'mytool' should appear; got:\n%s", out.String()) + } + if strings.Contains(out.String(), "defaulttool") { + t.Errorf("default home-derived tool 'defaulttool' must not appear when ToolsDir is set; got:\n%s", out.String()) + } +} diff --git a/internal/subcommand/trust.go b/internal/subcommand/trust.go index d7cd560..5dd9186 100644 --- a/internal/subcommand/trust.go +++ b/internal/subcommand/trust.go @@ -16,9 +16,10 @@ import ( // TrustOptions configures the Trust subcommand. Home and CWD are explicit so // tests can drive the dispatcher without touching the process environment. type TrustOptions struct { - Home string - CWD string - Reason string // for `trust .` / `trust ` + Home string + CWD string + Reason string // for `trust .` / `trust ` + TrustPath string // explicit path to trusted-repos.yaml; empty → derive from Home } // Trust dispatches based on args[0] (the verb): "list", "remove", "check", or @@ -82,8 +83,17 @@ func trustAddDot(out io.Writer, opts TrustOptions) int { return trustAdd(repo, out, opts) } +// loadTrust opens the trust database, preferring opts.TrustPath when set and +// falling back to the standard path derived from opts.Home. +func loadTrust(opts TrustOptions) (*trust.Trust, error) { + if opts.TrustPath != "" { + return trust.LoadFromPath(opts.TrustPath) + } + return trust.Load(opts.Home) +} + func trustAdd(path string, out io.Writer, opts TrustOptions) int { - tr, err := trust.Load(opts.Home) + tr, err := loadTrust(opts) if err != nil { fmt.Fprintf(out, "trust load: %v\n", err) return 2 @@ -101,7 +111,7 @@ func trustAdd(path string, out io.Writer, opts TrustOptions) int { } func trustList(out io.Writer, opts TrustOptions) int { - tr, err := trust.Load(opts.Home) + tr, err := loadTrust(opts) if err != nil { fmt.Fprintf(out, "trust load: %v\n", err) return 2 @@ -125,7 +135,7 @@ func trustList(out io.Writer, opts TrustOptions) int { } func trustRemove(path string, out io.Writer, opts TrustOptions) int { - tr, err := trust.Load(opts.Home) + tr, err := loadTrust(opts) if err != nil { fmt.Fprintf(out, "trust load: %v\n", err) return 2 @@ -143,7 +153,7 @@ func trustRemove(path string, out io.Writer, opts TrustOptions) int { } func trustCheck(path string, out io.Writer, opts TrustOptions) int { - tr, err := trust.Load(opts.Home) + tr, err := loadTrust(opts) if err != nil { fmt.Fprintf(out, "trust load: %v\n", err) return 2 diff --git a/internal/subcommand/trust_test.go b/internal/subcommand/trust_test.go index 4ad169c..5dcaff3 100644 --- a/internal/subcommand/trust_test.go +++ b/internal/subcommand/trust_test.go @@ -112,3 +112,44 @@ func TestTrust_CheckCWDDefault(t *testing.T) { t.Errorf("check from trusted repo cwd should exit 0, got %d, out=%q", code, out.String()) } } + +// TestTrust_CustomTrustPath proves that TrustOptions.TrustPath (sourced from +// cfg.Trust.Path in main.go) is used instead of the Home-derived default path. +// A repo added via the custom path is visible when listing with the same path, +// but NOT visible when listing via the default Home-derived path — confirming +// the custom path is honoured and does not accidentally share state. +func TestTrust_CustomTrustPath(t *testing.T) { + dir := t.TempDir() + home := filepath.Join(dir, "home") + customTrustPath := filepath.Join(dir, "custom", "trusted-repos.yaml") + repo := filepath.Join(home, "Code", "custom-project") + os.MkdirAll(repo, 0o755) + + // Add repo using the custom TrustPath. + var out bytes.Buffer + code := Trust([]string{repo}, &out, TrustOptions{ + Home: home, + CWD: repo, + TrustPath: customTrustPath, + }) + if code != 0 { + t.Fatalf("add with custom path exit=%d out=%q", code, out.String()) + } + + // List via the same custom path: repo should appear. + out.Reset() + code = Trust([]string{"list"}, &out, TrustOptions{Home: home, TrustPath: customTrustPath}) + if code != 0 { + t.Fatalf("list with custom path exit=%d out=%q", code, out.String()) + } + if !strings.Contains(out.String(), repo) { + t.Errorf("list via custom path should contain %q; got: %s", repo, out.String()) + } + + // List via the default Home-derived path: repo must NOT appear (separate file). + out.Reset() + Trust([]string{"list"}, &out, TrustOptions{Home: home}) + if strings.Contains(out.String(), repo) { + t.Errorf("list via default path must not see repo added to custom path; got: %s", out.String()) + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..aacd46c --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,115 @@ +// Copyright 2026 Undermountain Coding Company +// SPDX-License-Identifier: Apache-2.0 + +// Package telemetry provides a minimal, opt-in telemetry interface for failsafe. +// +// # v1 status: stub exporter only +// +// Telemetry is OFF by default (TelemetryConfig.Enabled = false). When disabled, +// New returns a no-op exporter that discards all events without any I/O. +// +// When enabled (opt-in via config), the returned exporter is still a stub in v1 — +// it accepts events and discards them, but includes the intended OTLP payload shape +// as documentation so it can be code-reviewed before a real backend ships. +// +// # Intended payload (v2 OTLP exporter — not implemented in v1) +// +// When a real exporter ships it will emit OTLP spans/events to OTLPEndpoint with +// the following attributes: +// +// span name: "failsafe.hook" +// failsafe.decision: "block" | "allow" | "allow_override" +// failsafe.tool: registry tool name (e.g. "kubectl", "terraform") +// failsafe.verb: parsed verb (e.g. "apply", "destroy") — omitted if empty +// failsafe.subverb: parsed subverb — omitted if empty +// failsafe.mode: "enabled" | "disabled" +// failsafe.version: failsafe binary version string +// +// No user-identifying data (hostname, username, file paths, command arguments, +// session IDs) is included. The payload is intentionally narrow: aggregate +// decision counts by tool/verb/mode, nothing else. +// +// # No OTLP dependency in v1 +// +// This package does not import any OpenTelemetry SDK. When the real exporter +// is added it will live in a separate build tag or sub-package so users who +// never enable telemetry incur zero binary size cost. +package telemetry + +import "github.com/UndermountainCC/failsafe/internal/config" + +// --------------------------------------------------------------------------- +// Event type +// --------------------------------------------------------------------------- + +// Event represents a single hook decision to be exported. +type Event struct { + // Decision is the outcome: "block", "allow", or "allow_override". + Decision string + // Tool is the registry tool name (e.g. "kubectl"). May be empty for + // non-tool decisions (e.g. parse failures). + Tool string + // Verb is the parsed verb. May be empty. + Verb string + // Subverb is the parsed subverb. May be empty. + Subverb string + // Mode is the failsafe mode at decision time: "enabled" or "disabled". + Mode string +} + +// --------------------------------------------------------------------------- +// Exporter interface +// --------------------------------------------------------------------------- + +// Exporter is the telemetry output interface. Implementations must be safe +// for concurrent use. +type Exporter interface { + // Export records an event. The call is best-effort: implementations must + // not block the hook path; they should drop events rather than stall. + Export(e Event) +} + +// --------------------------------------------------------------------------- +// No-op exporter +// --------------------------------------------------------------------------- + +// noopExporter silently discards all events. +type noopExporter struct{} + +func (noopExporter) Export(Event) {} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +// New returns an Exporter appropriate for cfg. +// +// - cfg.Enabled == false (the default): returns a no-op exporter. No I/O, +// no goroutines, no dependencies. +// +// - cfg.Enabled == true: returns a stub exporter (v1). Events are accepted +// and discarded. A real OTLP exporter will replace this stub in a future +// release; see the package-level doc comment for the intended payload shape. +// +// TODO: real OTLP exporter — replace stubExporter with an OTLP batch exporter +// that sends spans to cfg.OTLPEndpoint using the OpenTelemetry Go SDK. +func New(cfg config.TelemetryConfig) Exporter { + if !cfg.Enabled { + return noopExporter{} + } + // v1 stub: telemetry is configured but the real exporter hasn't shipped yet. + // Events are silently dropped. cfg.OTLPEndpoint is recorded here for when + // the real exporter is wired up. + return &stubExporter{endpoint: cfg.OTLPEndpoint} +} + +// stubExporter is the v1 "enabled but no-op" exporter. It accepts events and +// discards them, but documents where the real exporter will deliver them. +type stubExporter struct { + endpoint string // target OTLP endpoint — unused until real exporter ships +} + +func (s *stubExporter) Export(Event) { + // TODO: real OTLP exporter — batch-send events to s.endpoint. + // The intended payload shape is documented in the package-level doc comment. +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 0000000..30f8495 --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,68 @@ +// Copyright 2026 Undermountain Coding Company +// SPDX-License-Identifier: Apache-2.0 + +package telemetry + +import ( + "testing" + + "github.com/UndermountainCC/failsafe/internal/config" +) + +// TestTelemetryDefaultOff verifies that the default TelemetryConfig (Enabled=false) +// returns a no-op exporter that accepts Export calls without panicking or blocking. +func TestTelemetryDefaultOff(t *testing.T) { + cfg := config.TelemetryConfig{ + Enabled: false, + OTLPEndpoint: "", + } + exp := New(cfg) + if exp == nil { + t.Fatal("New returned nil exporter for disabled config") + } + // Must not panic, block, or produce any side effects. + exp.Export(Event{ + Decision: "allow", + Tool: "kubectl", + Verb: "get", + Mode: "enabled", + }) +} + +// TestTelemetryEnabledIsStub verifies that enabling telemetry in v1 still returns +// a stub (no-op) exporter — no real OTLP export occurs. +func TestTelemetryEnabledIsStub(t *testing.T) { + cfg := config.TelemetryConfig{ + Enabled: true, + OTLPEndpoint: "http://localhost:4317", + } + exp := New(cfg) + if exp == nil { + t.Fatal("New returned nil exporter for enabled config") + } + // Must not panic or attempt a network connection. + exp.Export(Event{ + Decision: "block", + Tool: "terraform", + Verb: "apply", + Mode: "enabled", + }) +} + +// TestExporterTypeDefaultOff confirms that the disabled path returns the no-op +// type, not the stub — this documents the intended type distinction. +func TestExporterTypeDefaultOff(t *testing.T) { + exp := New(config.TelemetryConfig{Enabled: false}) + if _, ok := exp.(noopExporter); !ok { + t.Errorf("disabled telemetry: expected noopExporter, got %T", exp) + } +} + +// TestExporterTypeEnabledIsStub confirms that the enabled path returns the stub +// type in v1. +func TestExporterTypeEnabledIsStub(t *testing.T) { + exp := New(config.TelemetryConfig{Enabled: true, OTLPEndpoint: "http://localhost:4317"}) + if _, ok := exp.(*stubExporter); !ok { + t.Errorf("enabled telemetry: expected *stubExporter, got %T", exp) + } +} diff --git a/internal/trust/trust.go b/internal/trust/trust.go index c45cc0e..40ccaf7 100644 --- a/internal/trust/trust.go +++ b/internal/trust/trust.go @@ -37,7 +37,12 @@ type fileShape struct { // Load reads the trust file from $HOME/.config/failsafe/trusted-repos.yaml. // A missing file is not an error; you get an empty Trust ready for Add. func Load(home string) (*Trust, error) { - path := filepath.Join(home, ".config", "failsafe", "trusted-repos.yaml") + return LoadFromPath(filepath.Join(home, ".config", "failsafe", "trusted-repos.yaml")) +} + +// LoadFromPath reads the trust file from an explicit path. +// A missing file is not an error; you get an empty Trust ready for Add. +func LoadFromPath(path string) (*Trust, error) { t := &Trust{file: path} body, err := os.ReadFile(path) if err != nil {