feat(age): support age encrypted env vars in mise.toml files#6463
Conversation
Adds the foundational support for encrypting environment variables using age encryption. This is marked as experimental and provides the core encryption/decryption functionality that will be integrated into `mise set` and env resolution. ## Key Features - New `agecrypt` module for age-based encryption/decryption - Support for both x25519 and SSH recipients - Automatic identity discovery from multiple sources - Storage format: `age64:zstd:v1:<base64>` for encrypted values - Settings for configuring age identity files and strict mode ## Implementation Details - Added age crate dependency with SSH support - Created comprehensive encryption/decryption helpers - Integrated with existing settings system - Compatible with SOPS age.txt file location ## Settings New `settings.age` configuration: - `identity_files`: List of age identity files for decryption - `key_file`: Primary age private key file (defaults to ~/.config/mise/age.txt) - `ssh_identity_files`: List of SSH identity files for decryption - `strict`: Whether to fail on decryption errors (default false) ## Tests - Unit tests for x25519 round-trip encryption/decryption - Tests for prefix detection and recipient parsing - All tests passing Next steps will integrate this into the CLI commands and env resolution pipeline. [experimental] 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Pull Request Overview
Adds foundational support for age encryption to encrypt environment variables, introducing the core encryption/decryption infrastructure without exposing user-facing commands yet. This establishes the groundwork for future PRs that will integrate age encryption into mise set and environment variable resolution.
- Implements complete age encryption/decryption workflow with x25519 and SSH recipient support
- Adds configuration settings for age identity management and encryption behavior
- Creates SOPS-compatible storage format and automatic key discovery mechanism
Reviewed Changes
Copilot reviewed 5 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/task/mod.rs | Adds agecrypt module declaration |
| src/task/agecrypt.rs | Core age encryption/decryption implementation with identity loading and recipient parsing |
| settings.toml | Defines experimental age configuration settings for identity files and encryption behavior |
| schema/mise.json | Updates JSON schema to include age configuration properties |
| Cargo.toml | Adds age crate dependency with SSH support and related utilities |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
| debug!( | ||
| "[experimental] No age identities found, returning ciphertext in non-strict mode" | ||
| ); |
There was a problem hiding this comment.
The debug! macro is used without importing it. Add use log::debug; or use tracing::debug; at the top of the file, or use the appropriate logging macro from the crate's logging framework.
| if Settings::get().age.strict { | ||
| return Err(eyre!("[experimental] Failed to decrypt: {}", e)); | ||
| } else { | ||
| debug!("[experimental] Failed to decrypt in non-strict mode: {}", e); |
There was a problem hiding this comment.
The debug! macro is used without importing it. Add use log::debug; or use tracing::debug; at the top of the file, or use the appropriate logging macro from the crate's logging framework.
| debug!( | ||
| "[experimental] Failed to read identity file {:?}: {}", | ||
| path, e | ||
| ); |
There was a problem hiding this comment.
The debug! macro is used without importing it. Add use log::debug; or use tracing::debug; at the top of the file, or use the appropriate logging macro from the crate's logging framework.
| debug!( | ||
| "[experimental] Failed to parse SSH identity from {:?}: {}", | ||
| path, e | ||
| ); |
There was a problem hiding this comment.
The debug! macro is used without importing it. Add use log::debug; or use tracing::debug; at the top of the file, or use the appropriate logging macro from the crate's logging framework.
| debug!( | ||
| "[experimental] Failed to read SSH identity file {:?}: {}", | ||
| path, e | ||
| ); |
There was a problem hiding this comment.
The debug! macro is used without importing it. Add use log::debug; or use tracing::debug; at the top of the file, or use the appropriate logging macro from the crate's logging framework.
- Add --age-encrypt and related flags to mise set command - Implement encryption in mise set with recipient collection - Add decryption to env resolution pipeline - Fix Send trait issues with Identity types - Support MISE_AGE_KEY for raw secret keys - Ensure encrypted values are auto-redacted 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Use age64:v1: prefix for uncompressed values (<1KB) - Use age64:zstd:v1: prefix for compressed values (>1KB) - Add separate tests for small and large value encryption - Support decryption of both formats This reduces overhead for small secrets while still providing compression benefits for larger values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Add comprehensive E2E tests for age encryption functionality - Test small vs large value compression behavior - Test multiple recipients, SSH keys, and key files - Add extensive documentation in environments/secrets.md - Update CLI help text with age encryption examples - Document differences from sops and use cases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
The age.identity_files and age.ssh_identity_files paths were not being expanded with replace_path() for tilde expansion and environment variables, unlike age.key_file. This inconsistency meant identity files specified with unexpanded paths (e.g., ~/age.txt or $HOME/age.txt) would not be found. Now all age identity file paths are consistently expanded using replace_path(). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
…mpt support - Move recipient collection outside encryption loop to avoid repeated I/O - Change encrypt_value to accept &[Recipients] instead of taking ownership - Add --prompt flag for interactive environment variable input - Mask prompted input when --age-encrypt is used for security - Simplify age encryption examples in help text 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Hyperfine Performance
|
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2025.9.22 x -- echo |
21.2 ± 0.4 | 20.4 | 23.2 | 1.00 ± 0.03 |
mise x -- echo |
21.2 ± 0.4 | 20.4 | 25.2 | 1.00 |
mise env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2025.9.22 env |
20.4 ± 0.5 | 19.7 | 25.4 | 1.00 |
mise env |
20.7 ± 0.4 | 19.8 | 22.8 | 1.01 ± 0.03 |
mise hook-env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2025.9.22 hook-env |
20.1 ± 0.4 | 19.3 | 22.3 | 1.00 |
mise hook-env |
20.3 ± 0.5 | 19.4 | 26.5 | 1.01 ± 0.03 |
mise ls
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2025.9.22 ls |
18.1 ± 0.4 | 17.4 | 22.5 | 1.00 |
mise ls |
18.5 ± 0.4 | 17.6 | 20.2 | 1.02 ± 0.03 |
xtasks/test/perf
| Command | mise-2025.9.22 | mise | Variance |
|---|---|---|---|
| install (cached) | 174ms | ✅ 109ms | +59% |
| ls (cached) | 66ms | 66ms | +0% |
| bin-paths (cached) | 73ms | 73ms | +0% |
| task-ls (cached) | 502ms | 499ms | +0% |
✅ Performance improvement: install cached is 59%
Changes the storage format from `FOO="age64:zstd:v1:<base64>"` to
`FOO={age = {value = "<base64>", format = "zstd"}}` for better structure and extensibility.
## Key Changes
- **New TOML format**: Use inline tables instead of prefixed strings
- **Age directive**: Added `EnvDirective::Age` variant for typed handling
- **Format enum**: `AgeFormat::Raw` (default) and `AgeFormat::Zstd` for compression
- **Improved decryption**: Direct detection of age directives vs string parsing
- **Module reorganization**: Moved agecrypt from `src/task/` to `src/agecrypt.rs`
## TOML Format Examples
```toml
[env]
# Small value (uncompressed)
API_KEY = { age = { value = "YWdlLWVuY3J5cHRpb24...", format = "raw" } }
# Large value (compressed)
DATABASE_URL = { age = { value = "KLUv/QBYuS4A...", format = "zstd" } }
```
## Implementation Details
- Added `update_env_age()` method for TOML serialization
- Updated CLI logic to use `create_age_directive()`
- Fixed decryption in `mise set KEY` commands
- Maintained backward compatibility with old string format
- Added proper error handling and fallback behavior
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Enhances e2e tests to verify age-encrypted environment variables work correctly with mise env in addition to mise set. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Age encryption should always fail when decryption keys are unavailable for better security. - Remove `age.strict` setting from settings.toml - Remove all strict mode conditionals from agecrypt.rs - Always return errors when no identities found or decryption fails - Update e2e tests to expect strict behavior (failures instead of fallbacks) - 🔒 **More secure**: No silent fallbacks to showing encrypted values - 🎯 **Simpler**: One behavior path instead of two modes - 🧪 **Experimental**: Strict behavior is appropriate for experimental features - ⚡ **Fail-fast**: Immediate feedback when keys are missing The age encryption feature now consistently fails when keys are unavailable, providing clear error messages instead of potentially exposing encrypted data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Fix clippy manual_strip warnings by using strip_prefix() - Remove unused encrypt_value() function (now using create_age_directive) - Clean up code formatting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Add Settings.ensure_experimental() checks for age encryption in CLI and env directive processing - Fix test compilation errors by updating to use new create_age_directive/decrypt_age_directive APIs - Ensure age encryption is properly gated behind experimental setting - Apply formatting fixes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Add MISE_EXPERIMENTAL=true environment variable to all mise commands in the age encryption e2e test to ensure experimental age encryption functionality is properly enabled during testing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Add Deserialize derive to EnvDirective enum to enable reading age-encrypted values from TOML - Move experimental check from directive processing to actual decryption for better UX - Remove unused Settings import to fix compiler warning This fixes the TOML parsing issue where age-encrypted values couldn't be read back from config files. The experimental check is now more targeted - it only triggers when actually decrypting age values, not when simply loading config that contains them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
d7dd901 to
8092f0a
Compare
Fixes inconsistent handling of age-encrypted value decryption failures between EnvDirective::Val and EnvDirective::Age. Both directive types now fail consistently when decryption fails, rather than having different fallback behaviors. Changes: - EnvDirective::Val: Remove graceful fallback, fail on decryption error - EnvDirective::Age: Remove graceful fallback, fail on decryption error - mise set: Fix logic to properly detect local config files and fail on decryption errors when explicitly requesting variable values - mise set: Use decrypt_age_directive for Age directives instead of decrypt_value to handle the correct data format 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
…sing Fixes inconsistent handling of age-encrypted value decryption failures between EnvDirective::Val and EnvDirective::Age directive types. ## Key Issues Fixed ### 1. TOML Parsing for Age Directives - Fixed serde deserialization conflict in Val enum where Age variant wasn't being parsed correctly - Reordered enum variants to prioritize Age parsing and removed conflicting flatten annotations - Resolves "data did not match any variant of untagged enum Val" errors ### 2. Config Path Detection in mise set - Fixed config path detection logic to properly find local config files during e2e tests - Added explicit checks for mise.toml and .mise.toml in current directory - Ensures mise set can find age-encrypted values in local config files ### 3. Decryption Error Consistency - Both EnvDirective::Val and EnvDirective::Age now fail consistently when decryption fails - Removed graceful fallback behavior that was causing inconsistent runtime behavior - Ensures predictable error handling across directive types ## Test Results - E2E test now passes most assertions (previously 100% failure rate) - mise set command works correctly with age-encrypted values - mise env command works with single age-encrypted values - Both raw and zstd compression formats working correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
…mand Fixes two bugs in the environment variable retrieval logic: 1. **Double iteration bug**: The get() method was calling config.env_entries() twice when retrieving age-encrypted values, causing unnecessary performance overhead. Fixed by capturing env_entries once and reusing it. 2. **Age format mismatch**: When retrieving EnvDirective::Age values, the code was incorrectly using agecrypt::decrypt_value() which expects prefixed format (age64:zstd:v1:) on raw base64 ciphertext, causing "age encryption prefix not found" errors. Fixed by using agecrypt::decrypt_age_directive() for EnvDirective::Age and agecrypt::decrypt_value() only for old prefixed formats. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Fixes test failure in multi-recipient age encryption by creating a clean environment for testing the second recipient key. Previously the test was mixing single-recipient and multi-recipient variables in the same environment, causing the second key to fail when trying to decrypt variables that were encrypted with only the first key. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Add support for simplified age format: `{age = "value"}` vs `{age = {value = "...", format = "zstd"}}`
- Omit `format = "raw"` when it's the default format
- Use simplified format when only value is provided
- Enhanced TOML parsing with AgeSimple/AgeComplex variants to handle both formats
- Updated JSON schema to support both age format variants
- Added comprehensive e2e test for age format consistency and redaction
- Fixed documentation dead links by adding trailing slashes
- Verified age-encrypted values are properly redacted in task execution
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- Resolved conflict in src/cli/set.rs by keeping age imports and adding new ConfigPathOptions/resolve_target_config_path imports - Resolved conflict in e2e/cli/test_set_use_consistency by keeping age encryption tests - Fixed unused import and formatting issues
- Make EnvDirectiveOptions.redact nullable to distinguish explicit vs default values
- Age-encrypted values now default to redacted for security unless explicitly set to redact = false
- Reorder TOML parsing variants so AgeWithOptions matches before AgeSimple
- Add comprehensive e2e test for age encryption redaction behavior
- Support both simple age format {age = "value"} and options format {age = "value", redact = false}
- Ensure proper security-by-default while allowing explicit non-redaction when needed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
…ield - Update test snapshots to reflect Option<bool> type for redact field - Fix venv.rs compilation errors by changing redact: false to redact: Some(false) - All unit tests now passing with no clippy warnings
Replace direct age-keygen calls with 'mise x age -- age-keygen' to ensure age is available in CI environments where it may not be installed globally
- Use 'mise x age' instead of direct age-keygen to ensure age is available in CI - Work around circular dependency when mise.toml contains encrypted values by temporarily moving config file when generating second age key - Remove redundant nested match in EnvDirective::Age resolution logic that contained unreachable dead code
…pted values Previously, when age decryption failed, the code would fall back to displaying the encrypted value, which is a security issue. Now all decryption failures properly return an error with bail!() to prevent exposing encrypted data.
- Refactor get() method to use config.env_with_sources() for global config which already handles all decryption centrally - Extract decrypt_value_if_needed() helper to reduce duplication in local config handling - Remove manual decryption logic from run() method, use Config's centralized env loading instead - Clean up unused imports and variables
- Removed support for deprecated age64:* string encryption format
- Removed is_age_encrypted() and decrypt_value() functions from agecrypt.rs
- Removed AgeSimple TOML variant, consolidated to AgeWithOptions
- Simplified age format now uses AgeWithOptions with default options
- All age directives now consistently use the same parsing path
This simplifies the codebase by removing legacy support for the old
format while maintaining backward compatibility for the simplified
{age = "value"} syntax through AgeWithOptions with default options.
Resolved conflict in mise.lock by keeping both linux-x64 and macos-arm64 platforms for the bun tool.
### 📦 Registry - add ggshield by @TyceHerrman in [#6435](#6435) - add jaq by @TyceHerrman in [#6434](#6434) ### 🚀 Features - **(age)** support age encrypted env vars in mise.toml files by @jdx in [#6463](#6463) ### 🐛 Bug Fixes - **(vfox)** integrate `parse_legacy_file` into backend by @malept in [#6471](#6471) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Bumps to 2025.9.24 with age‑encrypted env var support, registry additions (ggshield, jaq, patterner), vfox fix, and lockfile/tooling updates. > > - **Release** > - Bump version to `2025.9.24` across `Cargo.toml`, `Cargo.lock`, `default.nix`, `packaging/rpm/mise.spec`, `README.md`, and shell completions; update `CHANGELOG.md`. > - **Features** > - Support age‑encrypted env vars in `mise.toml`. > - **Registry** > - Add `ggshield`, `jaq` entries; add `tailor-platform/patterner` in `crates/aqua-registry/.../patterner/registry.yaml`. > - **Bug Fixes** > - `(vfox)` integrate `parse_legacy_file` into backend. > - **Dependencies/Tooling** > - Update `mise.lock`: `usage-cli` → 2.3.2, `hk` → 1.15.7, `sops` → 3.11.0. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0b0708b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: mise-en-dev <release@mise.jdx.dev>
## Summary - add missing task sandbox fields to the mise task schemas - add top-level `env_file`/`dotenv`/`env_path` schema entries and mark them deprecated as legacy shortcuts - allow env age directive options, tighten complex age option nesting, and cover the schema fixture - keep sandbox fields in a task-only schema overlay so they validate for `[tasks.*]` but do not leak into `[task_templates.*]`, whose Rust type does not deserialize/apply them ## Context - The renderer builds both task and `task_template` schemas from `task_props`. The new `taskOnlyProps` overlay is for fields accepted by `Task` but not `TaskTemplate`; this mirrors the existing task-only treatment for `extends`. - `env_file`, `dotenv`, and `env_path` are still accepted by current serde parsing, but they are legacy top-level shortcuts. #1361 marked `env_file`/`env_path` deprecated in favor of `env.mise.file`/`env.mise.path`; #1519 later rewrote env parsing and kept accepting `env_file`, alias `dotenv`, and `env_path` without a runtime deprecation warning. The schema keeps them valid but marks them deprecated to point users at `[env] _.file` / `_.path`. - Task sandbox config fields were introduced with process sandboxing in #8845; `allow_env` wildcard semantics were later expanded in #8974. - Age env directives, including flattened `EnvDirectiveOptions` on age values, were introduced in #6463. This PR now mirrors the Rust variants more closely: top-level age options are allowed with `age = "..."`, while complex `age = { value = ... }` options must be nested inside the `age` object. ## Verification - `bun xtasks/render/schema.ts` - `jq empty schema/mise.json schema/mise-task.json schema/miserc.json` - `git diff --check` - `mise run test:e2e e2e/config/test_schema_tombi`
Summary
Adds the foundational support for encrypting environment variables using age encryption. This is the first PR in a series that will implement full age encryption support for
mise setand environment variable resolution.Features
agecryptmodule for age-based encryption/decryptionage64:zstd:v1:<base64>for encrypted valuesImplementation Details
src/task/agecrypt.rsSettings
New
settings.ageconfiguration (all marked [experimental]):identity_files: List of age identity files for decryptionkey_file: Primary age private key file (defaults to~/.config/mise/age.txt)ssh_identity_files: List of SSH identity files for decryptionstrict: Whether to fail on decryption errors (default false)Default Key Discovery
When no explicit recipients are provided, the system will try:
settings.age.key_file(if configured)~/.config/mise/age.txt(SOPS-compatible fallback)~/.ssh/id_ed25519,~/.ssh/id_rsaTests
✅ Unit tests included for:
age64:zstd:v1:)Next Steps (Future PRs)
mise setcommand (--age-encrypt,--age-recipient, etc.)mise setNotes
🤖 Generated with Claude Code
Note
Adds experimental age encryption for env vars (inline in mise.toml) with decryption in env resolution, new CLI set flags, settings, schemas, docs, and tests.
src/agecrypt.rs(age encrypt/decrypt, x25519/SSH recipients, zstd compression, identity discovery).EnvDirective::Agewith parsing/templating; default redaction; resolve-time decryption; makeEnvDirectiveOptions.redactoptional.mise_tomlto write age directives; update snapshots.src/main.rs.mise setwith--prompt,--age-encrypt,--age-recipient,--age-ssh-recipient,--age-key-file; decrypt when reading values; support age writes.[settings.age](identity_files,key_file,ssh_identity_files).schema/mise*.jsonandmise-task.jsonto supportenvage forms and new settings.setCLI docs; fix secrets links.test_env_age_encryptionand unit tests for age round-trips/recipient parsing.agecrate (with SSH), related crypto deps; listageinCargo.tomlandCargo.lock.Written by Cursor Bugbot for commit ec92504. This will update automatically on new commits. Configure here.