Skip to content
Draft
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
[0.2.0]: https://github.com/runedeck/rune/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/runedeck/rune/releases/tag/v0.1.0

## [Unreleased]

### Added

- Read an unset `from_env` key through secretspec from the manifest `secrets` or `RUNE_SECRETS` names, or from `~/.config/rune/secretspec.toml` when no env file is named.

### Changed

- Skip the env file when a secrets manifest applies. A failed read names the key and the manifest, and never repeats secretspec's report, which can quote a value.

## [0.6.0] - 2026-10-26

### Added
Expand Down
5 changes: 5 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
# Rune already ships sol@claude and grok@claude with these localhost
# CLIProxyAPI defaults. Repeating a profile here replaces that built-in profile,
# which makes this file a starting point for endpoint or model overrides.
#
# A `from_env` key the shell does not export is read through secretspec from
# the manifest below. Without the key, Rune uses ~/.config/rune/secretspec.toml
# when that file exists, and otherwise the env file (key `env`, default ~/.env).
# secrets: ~/.config/rune/secretspec.toml
launch:
models:
sol:
Expand Down
5 changes: 3 additions & 2 deletions docs/Manual Testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,7 @@ rune launch nonexistent@claude # error listing known profiles
rune launch claude -- --version # real execution: args after -- pass through
```

The invocation is `[profile@]<tool>`, user@host style: `sol@claude` is profile `sol` running at tool `claude`. Profiles live under `launch.profiles` in `~/.config/rune/config.yaml`. Environment values support `from_env` references so secrets stay out of config. An unset process variable falls back to the env file (`rune config set env <path>`, default `~/.env`).
The invocation is `[profile@]<tool>`, user@host style: `sol@claude` is profile `sol` running at tool `claude`. Profiles live under `launch.profiles` in `~/.config/rune/config.yaml`. Environment values support `from_env` references so secrets stay out of config. An unset process variable is read through `secretspec get` from the secrets manifest (`rune config set secrets <path>`, default `~/.config/rune/secretspec.toml` when it exists). Without a manifest it falls back to the env file (`rune config set env <path>`, default `~/.env`).

Model routes live under `launch.models`. A profile selects one route with `model`, which keeps the provider model and context settings together. Start the local translating proxy used by the Sol and Lumo profiles, then configure the routes and profiles:

Expand Down Expand Up @@ -846,7 +846,8 @@ launch:
```sh
rune launch
rune launch sol@claude --dry-run
env -u CLIPROXY_API_KEY RUNE_ENV=/dev/null rune launch sol@claude --dry-run
env -u CLIPROXY_API_KEY RUNE_ENV=/dev/null RUNE_SECRETS=/nonexistent/secretspec.toml rune launch sol@claude --dry-run
env -u CLIPROXY_API_KEY rune launch sol@claude --dry-run
rune launch sol@claude
```

Expand Down
52 changes: 52 additions & 0 deletions docs/changes/launch-secret-declaration-lookup/adr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
title: "Launch secrets are read through the secretspec binary"
description: "A from_env reference that the process environment leaves unset is read with the secretspec binary against one manifest; the env file is the fallback only on a machine without a manifest, and a trait keeps room for the SDK."
type: adr
category: architecture
tags:
- cli
- launch
- secrets
status: proposed
created: 2026-11-01
updated: 2026-11-01
author: "@N4M3Z"
project: cli
related:
- "CLI-0021 Launch Profile Composition"
responsible: ["@N4M3Z"]
accountable: ["@N4M3Z"]
consulted: ["claude-opus-5-5@claude"]
informed: []
upstream:
- https://github.com/cachix/secretspec
change: launch-secret-declaration-lookup
---

# Launch secrets are read through the secretspec binary

## Context and Problem Statement

Launch profiles keep credentials out of config with `from_env` references. An unset reference falls back to a plaintext env file. The owner wants every secret in the stack declared once and held in a real secret store: the macOS Keychain first, then Proton Pass and the `pass` store, with dotenv still supported. SecretSpec does that for any project that carries a `secretspec.toml` ([SecretSpec][SECRETSPEC]). The question is how rune reads through it.

## Considered Options

1. Run `secretspec get` for each unset key, against one manifest.
2. Link the secretspec Rust SDK and resolve in process.
3. Wrap the launched harness in `secretspec run`, which exports every declared secret into the child.
4. Keep the env file and document secretspec as a separate shell step.

## Decision Outcome

Option 1. When a manifest applies, the launcher MUST read each unset `from_env` key with `secretspec get` against that manifest, MUST pass a reason and the caller fields, and MUST NOT read the env file. Without a manifest, the env file stays the fallback. The `SecretSource` trait keeps option 2 open as a later implementation behind a cargo feature.

Option 2 adds the SDK and its provider clients to every build of rune, for behavior the binary already has. Option 3 exports secrets under their declared names, but a profile maps a source key to a different target key (`ANTHROPIC_AUTH_TOKEN` from `CLIPROXY_API_KEY`), and it would put every declared secret into the harness when the profile names one or two. Option 4 leaves the plaintext file as the primary store.

## Consequences

- Launch credentials can live in the Keychain, Proton Pass, or `pass`. The manifest's provider chain decides, and the owner changes it without a rune release.
- Rune needs the `secretspec` binary on PATH when a manifest applies. A missing binary stops the launch with a message that names it.
- Each read is visible in secretspec's audit log with the launch that made it.
- The env file reader stays for machines without a manifest. It is a second code path to keep until the owner retires `~/.env`.

[SECRETSPEC]: https://github.com/cachix/secretspec
39 changes: 39 additions & 0 deletions docs/changes/launch-secret-declaration-lookup/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Launch secret resolution design

## Approach

The launch resolver keeps its first step: a `from_env` reference that the process environment sets wins. The second step becomes a choice made once per launch. When a secretspec manifest applies, rune runs `secretspec get <KEY>` against it for each unset key. When no manifest applies, rune reads the env file as before. The two sources never mix, so a stale `~/.env` cannot answer for a key that the manifest's provider chain failed to read.

The env file still works through the manifest. A manifest can end its provider chain with `dotenv://~/.env`, and secretspec then reads the file itself. That keeps the owner's existing keys usable while they move into the Keychain.

## Structure

- `src/ontology.rs`: `Config.secrets` and `ResolvedConfig.secrets`, resolved from `RUNE_SECRETS`, then config. `ResolvedConfig::secrets_manifest_in(config_dir)` returns a named path whether or not it exists, so a typo fails at the first read, and an empty name turns the manifest off. With nothing named, a named env file wins, and otherwise the default `secretspec.toml` in the config directory applies when it exists.
- `src/cli/ontology.rs`: `secrets` joins the scalar keys, so `rune config set secrets <path>` works.
- `src/cli/launch/secrets.rs`: the `SecretSource` trait and `SecretspecBinary`, which builds the argument list, runs the binary with stdin closed, and returns stdout byte for byte. A failure becomes one cause from a fixed list. Secretspec's own report never reaches rune's message: a dotenv parse error quotes the failing line, which can hold another key's value, and `rune run --json` writes the message to its output file.
- `src/cli/launch/mod.rs`: `SecretFallback` selects the manifest or the env file. `apply_profile_env` keeps one value per key for the launch, so two profile values that name one key cause one read.

## Commands

| Requirement | Command and behavior |
| --- | --- |
| Credentials read through the secret manager | `secretspec get <KEY> --file <manifest>` for each `from_env` key the process environment leaves unset |
| Process environment first | `std::env::var(<KEY>)` before any lookup |
| Named declaration | config key `secrets`, environment override `RUNE_SECRETS`, `rune config set secrets <path>` |
| Default declaration | `~/.config/rune/secretspec.toml` when it exists and neither `env` nor `RUNE_ENV` is set, else the env file (default `~/.env`). `RUNE_SECRETS=` turns the manifest off |
| Failed read stops the launch | non-zero exit from `secretspec get`, a missing binary, or a missing manifest returns an error before spawn. The cause comes from `failure_cause`, and the message points at `secretspec check --file <manifest> --explain` |
| Read reason recorded | `--reason "rune resolves the <invocation> launch profile and reads <KEY> for its environment" --caller rune --caller-version <version> --caller-operation resolve-profile` |
| One read per credential | a per-launch map from key to value in `apply_profile_env` |

## Linking the SDK later

The owner asked for the binary now and the library prepared. `SecretSource` is the seam. A second implementation that calls the secretspec Rust SDK fits behind a cargo feature without a change to `apply_profile_env`. This change adds no dependency: the SDK pulls every provider client into the build, and the binary already gives the same chain, profile, and audit behavior.

## Risks

- A new process per key adds latency. A profile names one or two keys, and a Keychain read is fast. The per-launch map removes repeats.
- SecretSpec requires a reason from a process that it classifies as an agent ([require_reason][REASON]). Rune always passes one, so `rune run` from inside a harness does not fail on the policy.
- `SECRETSPEC_PROFILE` and `SECRETSPEC_PROVIDER` in the parent environment reach the child and change what it reads. That is the documented secretspec behavior, and rune does not override it.
- A dry run and `--check` read the credentials too, because both resolve the full plan. Their output redacts credential values, as before.

[REASON]: https://secretspec.dev/reference/configuration/#requiring-a-reason-for-secret-access
34 changes: 34 additions & 0 deletions docs/changes/launch-secret-declaration-lookup/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
adr: docs/changes/launch-secret-declaration-lookup/adr.md
status: proposed
decisions: ["Launch secrets are read through the secretspec binary"]
---

# Launch secret resolution

## Why

A launch profile names its credential by reference, for example `ANTHROPIC_AUTH_TOKEN: { from_env: CLIPROXY_API_KEY }`. When the shell does not export the key, rune reads it from a plaintext env file, `~/.env` by default. Every proxy client key on the owner's machine lives in that file today.

SecretSpec declares the secrets a project needs in one TOML file and reads them from a chain of providers: the macOS Keychain, Proton Pass, the `pass` store, a dotenv file, and others ([SecretSpec][SECRETSPEC]). Reading launch credentials through it moves them out of plaintext without a second resolver in rune.

## What Changes

- A new config key `secrets` (environment override `RUNE_SECRETS`) names a secretspec manifest. With no key set, rune uses `~/.config/rune/secretspec.toml` when that file exists.
- When a manifest applies, a `from_env` reference that the process environment leaves unset is read with `secretspec get` against that manifest. The env file is not read.
- Without a manifest, a launch behaves as before: process environment, then the env file.
- Each read passes a reason and caller fields, so secretspec's audit log names the launch and the key.
- A missing manifest, a missing `secretspec` binary, or a failed read stops the launch with the key, the manifest path, and secretspec's reason. No value is printed.
- `rune config set secrets <path>` and `rune config` list the new key. The launch help text describes the order.

## Capabilities

- launch-secret-declaration-lookup (new)

## Impact

- `src/ontology.rs`, `src/cli/ontology.rs`, `src/cli/launch/mod.rs`, a new `src/cli/launch/secrets.rs`, their tests, `docs/config-reference.json`, `config.example.yaml`, the launch help text, and `CHANGELOG.md`.
- `rune run` resolves its plan through the launch resolver, so it gains the same order.
- The owner's machine needs the manifest at `~/.config/rune/secretspec.toml`. The provision repository deploys it, and it lies outside this repository.

[SECRETSPEC]: https://github.com/cachix/secretspec
24 changes: 24 additions & 0 deletions docs/changes/launch-secret-declaration-lookup/pull-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
`rune launch` and `rune run` read a profile's `from_env` key that the shell leaves unset through secretspec, from a manifest the new `secrets` config key names.

## Plan

Change `launch-secret-declaration-lookup`: proposal, design, one ADDED delta (`launch-secret-declaration-lookup`), tasks, and `adr.md` under `docs/changes/launch-secret-declaration-lookup/`.

## Changes

- Add the `secrets` config key and the `RUNE_SECRETS` override (`src/ontology.rs`). With neither set and no env file named, use `~/.config/rune/secretspec.toml` when it exists. An empty value turns the manifest off. `rune config set secrets <path>` works.
- Add `src/cli/launch/secrets.rs`: the `SecretSource` trait and `SecretspecBinary`, which runs `secretspec get <KEY> --file <manifest>` with a reason and `--caller rune`, and returns stdout byte for byte.
- Read through the manifest in `apply_profile_env` when one applies, and never read the env file then. Without a manifest, keep the env file fallback. Read each key once per launch.
- Stop the launch on a missing manifest, a missing binary, or a failed read, with the key, the manifest path, and a cause from a fixed list. Secretspec's own error text is never repeated, because a dotenv parse error quotes a stored value.
- Update the launch help text, `config.example.yaml`, `docs/config-reference.json`, `docs/Manual Testing.md`, and `CHANGELOG.md`.

## Testing

- `cargo test --workspace --all-features`: every test binary passes. New tests cover the manifest read, the environment winning, one read per key, a failed read, a missing manifest, the argument list, and a fake `secretspec` binary.
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` and `cargo fmt --check` clean.
- `rune spec validate launch-secret-declaration-lookup` passes.
- One live dry run of `sol@claude` with no shell variable and no env file: secretspec's audit log shows the Keychain served `CLIPROXY_API_KEY` with rune's reason and caller.

## Release Notes

- `rune launch` and `rune run` read unset `from_env` keys through secretspec from `~/.config/rune/secretspec.toml`, or the manifest that `secrets` or `RUNE_SECRETS` names. Without a manifest, the env file works as before.
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
## ADDED Requirements

### Requirement: Credentials read through the secret manager

When a launch profile names a credential that the process environment does not set, and a secret declaration applies, the launcher MUST read the credential through the secret manager against that declaration and MUST NOT read the environment file. A secret declaration is the file that lists the secrets a launch may need and the providers that hold them.

#### Scenario: Credential absent from the environment

- **WHEN** a profile names a credential that the process environment does not set and a secret declaration applies
- **THEN** the launcher reads the credential through the secret manager
- **AND** the environment file stays unread

### Requirement: Process environment first

The launcher MUST take a credential from the process environment when the environment sets it, and MUST NOT ask the secret manager for that credential.

#### Scenario: Credential set in the environment

- **WHEN** the process environment sets a credential that a profile names
- **THEN** the launch uses the environment value
- **AND** the secret manager receives no request for that credential

### Requirement: Named declaration

The launcher MUST use the secret declaration that the user configuration or the process environment names, and the process environment MUST win over the configuration. When the named declaration does not exist, the launcher MUST stop before the harness starts and MUST report the path.

#### Scenario: Named declaration missing

- **WHEN** the configuration names a secret declaration that does not exist and a profile needs a credential from it
- **THEN** the launch stops before the harness starts
- **AND** the report names the credential and the missing path

### Requirement: Default declaration

When nothing names a secret declaration and the user names no environment file, the launcher MUST use the declaration in the user configuration directory if that file exists. Otherwise it MUST read an unset credential from the environment file, as it did before secret declarations existed. An empty declaration name MUST turn the declaration off.

#### Scenario: No declaration on the machine

- **WHEN** nothing names a secret declaration and the configuration directory holds none
- **THEN** the launcher reads an unset credential from the environment file

#### Scenario: Named environment file beside a default declaration

- **WHEN** the user names an environment file, names no secret declaration, and the configuration directory holds one
- **THEN** the launcher reads an unset credential from the named environment file

### Requirement: Failed read stops the launch

When the secret manager cannot supply a credential, the launcher MUST stop before the harness starts. The report MUST name the credential, the declaration path, and a cause from a fixed list, and MUST NOT contain a credential value. The report MUST NOT repeat the secret manager's own error output, because that output can quote a stored value.

#### Scenario: Secret manager refuses

- **WHEN** the secret manager reports that it cannot supply a credential
- **THEN** the launch stops before the harness starts
- **AND** the report names the credential, the declaration, and the cause
- **AND** no line of the secret manager's error output appears in the report

### Requirement: Read reason recorded

Every read through the secret manager MUST carry a reason that names the launch profile and the credential, and MUST identify rune as the caller. The secret manager's audit log then shows which profile read which credential.

#### Scenario: Audit entry for a launch

- **WHEN** the launcher reads a credential through the secret manager
- **THEN** the request carries a reason that names the launch profile and the credential
- **AND** the request names rune as the caller

### Requirement: One read per credential

The launcher MUST read each credential at most once per launch, even when several profile values name the same credential.

#### Scenario: Two values name one credential

- **WHEN** two profile values name the same credential
- **THEN** the secret manager receives one request for it
- **AND** both values carry the same credential
17 changes: 17 additions & 0 deletions docs/changes/launch-secret-declaration-lookup/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## 1. Implementation

- [x] 1.1 `secrets` config key with the `RUNE_SECRETS` override, `secrets_manifest()`, and `rune config set secrets`
- [x] 1.2 `src/cli/launch/secrets.rs`: `SecretSource` and `SecretspecBinary` with the reason and caller arguments
- [x] 1.3 `SecretFallback` in `apply_profile_env`: environment first, then the manifest or the env file, one read per key
- [x] 1.4 Launch help text, `config.example.yaml`, `docs/config-reference.json`, and `CHANGELOG.md`

## 2. Verification

- [x] 2.1 `cargo test` covers each scenario: a fake secret source and a fake `secretspec` binary
- [x] 2.2 One live dry run against the owner's Keychain through `~/.config/rune/secretspec.toml`: the audit log shows `provider: keyring`, the reason, and `caller: rune`
- [ ] 2.3 Recorded proof per scenario under `docs/proofs/launch-secret-declaration-lookup/`
- [x] 2.4 `cargo clippy --all-targets --all-features -- -D warnings`, `cargo fmt --check`, `rune spec validate launch-secret-declaration-lookup`

## 3. Record

- [ ] 3.1 Archive moves `adr.md` to `docs/decisions/` with the next free CLI number
5 changes: 5 additions & 0 deletions docs/config-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,11 @@
"key": "ontology.work",
"type": "string | null"
},
{
"default": null,
"key": "secrets",
"type": "string | null"
},
{
"default": null,
"key": "setup",
Expand Down
Loading
Loading