Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 29 additions & 17 deletions cmd/failsafe/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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":
Expand All @@ -29,14 +37,15 @@ 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":
cwd, _ := os.Getwd()
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 {
Expand Down Expand Up @@ -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()
Expand All @@ -74,30 +84,32 @@ 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":
if len(args) < 3 || args[2] != "list" {
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")
Expand All @@ -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":
Expand All @@ -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:
Expand All @@ -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:])
Expand Down
24 changes: 24 additions & 0 deletions docs/explanation/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
159 changes: 140 additions & 19 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
| `<absolute-path>` | 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

Expand Down Expand Up @@ -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. |
| `<absolute-path>` | 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.

---

Expand Down
Loading
Loading