Skip to content

Commit b6f66c8

Browse files
authored
feat(config): unify env and forced_env with interpolation support (#6)
* feat(config): unify env and forced_env with interpolation support Merge `env` and `forced_env` into a single `env` key with three value types: - Exact credential reference: value matches a defined credential name - Template interpolation: `{{ credential-name }}` substituted inline - Literal value: no credential refs, used as-is All env entries are now admin-controlled (forced) and cannot be overridden by client requests. Changes: - Add interpolate.go with FindCredentialRefs, Interpolate, ResolveEnvValue - Update config validation to support unified env with template refs - Update executor to use ResolveEnvValue for all three value types - Deprecate forced_env (still works with warning) - Update README with new env syntax documentation BREAKING: None - forced_env still works (deprecated) * fix: address PR review comments - Remove unused NormalizeCredentialRef function (dead code) - Improve error message to include credential name for debugging * fix(config): remove unused IsExactCredentialRef function Dead code - logic already inlined in ClassifyEnvValue.
1 parent adf8653 commit b6f66c8

7 files changed

Lines changed: 662 additions & 21 deletions

File tree

README.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,22 +211,34 @@ tools:
211211
By default, blocked patterns run in `arg` mode (each argument is matched independently). Use
212212
`match: command` when a regex needs to span multiple args (for example `repo\\s+delete`).
213213

214-
### Forced environment variables
214+
### Environment variables
215215

216-
Variables that are always set and cannot be overridden by the agent:
216+
The `env` key supports three value types — all entries are admin-controlled and cannot be overridden by the agent:
217217

218218
```yaml
219+
credentials:
220+
gog-keyring-password:
221+
source: pass:gog/keyring
222+
db-password:
223+
source: op://vault/db/password
224+
219225
tools:
220226
gog:
221227
binary: /home/linuxbrew/.linuxbrew/bin/gog
222228
env:
229+
# Credential reference: value matches a defined credential name
223230
GOG_KEYRING_PASSWORD: gog-keyring-password
224-
forced_env:
231+
232+
# Template interpolation: {{ name }} substituted inline
233+
DATABASE_URL: "postgres://app:{{ db-password }}@localhost/mydb"
234+
235+
# Literal value: no credential refs, used as-is
225236
GOG_ENABLE_COMMANDS: 'gmail,calendar,drive,tasks,contacts,keep,time'
226237
```
227238

228-
The agent cannot change `GOG_ENABLE_COMMANDS` — it's stripped from inherited environment and set by
229-
the daemon.
239+
The agent cannot override any `env` entry — values are stripped from inherited environment and set by the daemon.
240+
241+
> **Deprecated:** `forced_env` is deprecated. Use `env` instead — literal values (without credential refs) work the same way.
230242

231243
### Output redaction
232244

internal/config/config.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,11 @@ type CredentialDef struct {
171171

172172
// ToolDef defines a wrapped tool.
173173
type ToolDef struct {
174-
Binary string `yaml:"binary"`
175-
Timeout string `yaml:"timeout,omitempty"`
176-
Env map[string]string `yaml:"env,omitempty"`
174+
Binary string `yaml:"binary"`
175+
Timeout string `yaml:"timeout,omitempty"`
176+
Env map[string]string `yaml:"env,omitempty"` // Unified env: credential refs, {{ interpolation }}, or literals
177+
// Deprecated: Use Env instead. ForcedEnv values are always treated as literals.
178+
// Will be removed in a future version.
177179
ForcedEnv map[string]string `yaml:"forced_env,omitempty"`
178180
Mode string `yaml:"mode,omitempty"` // "blocklist" (default) or "allowlist"
179181
BlockedArgs []BlockedArg `yaml:"blocked_args,omitempty"`
@@ -282,15 +284,24 @@ func (c *Config) Validate() error {
282284
log.Printf("[WARN] tool %q: binary %q not found on disk: %v", toolName, tool.Binary, err)
283285
}
284286

285-
for envVar, credName := range tool.Env {
287+
// Build credential names set for validation
288+
credNames := CredentialNamesSet(c.Credentials)
289+
290+
// Validate env entries (unified: credential refs, {{ interpolation }}, or literals)
291+
for envVar, value := range tool.Env {
286292
if !envVarNameRegex.MatchString(envVar) {
287293
return fmt.Errorf("tool %q: invalid env var name %q", toolName, envVar)
288294
}
289-
if _, ok := c.Credentials[credName]; !ok {
290-
return fmt.Errorf("tool %q: references undefined credential %q", toolName, credName)
295+
// Validate any credential references in the value
296+
if missing := ValidateEnvRefs(value, credNames); len(missing) > 0 {
297+
return fmt.Errorf("tool %q: env %q references undefined credential(s): %v", toolName, envVar, missing)
291298
}
292299
}
293300

301+
// Validate forced_env (deprecated) - emit warning and validate var names
302+
if len(tool.ForcedEnv) > 0 {
303+
log.Printf("[WARN] tool %q: forced_env is deprecated, use env instead (values without credential refs are treated as literals)", toolName)
304+
}
294305
for envVar := range tool.ForcedEnv {
295306
if !envVarNameRegex.MatchString(envVar) {
296307
return fmt.Errorf("tool %q: invalid forced_env var name %q", toolName, envVar)

internal/config/config_test.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -806,24 +806,43 @@ func TestValidate_EmptyBinary(t *testing.T) {
806806
}
807807

808808
func TestValidate_MissingCredentialRef(t *testing.T) {
809+
// With unified env, plain values are literals (allowed).
810+
// Template refs {{ name }} must reference defined credentials.
809811
cfg := &Config{
810812
Credentials: map[string]CredentialDef{}, // no credentials defined
811813
Tools: map[string]ToolDef{
812814
"gh": {
813815
Binary: "/usr/bin/gh",
814-
Env: map[string]string{"GH_TOKEN": "nonexistent-cred"},
816+
Env: map[string]string{"GH_TOKEN": "{{ nonexistent-cred }}"},
815817
},
816818
},
817819
}
818820
err := cfg.Validate()
819821
if err == nil {
820-
t.Fatal("Validate() should reject undefined credential ref")
822+
t.Fatal("Validate() should reject undefined credential ref in template")
821823
}
822824
if !strings.Contains(err.Error(), "undefined credential") {
823825
t.Errorf("error = %v, want 'undefined credential'", err)
824826
}
825827
}
826828

829+
func TestValidate_LiteralEnvValue(t *testing.T) {
830+
// Plain values without {{ refs }} are treated as literals (allowed).
831+
cfg := &Config{
832+
Credentials: map[string]CredentialDef{},
833+
Tools: map[string]ToolDef{
834+
"gh": {
835+
Binary: "/usr/bin/gh",
836+
Env: map[string]string{"PATH": "/usr/bin:/bin"},
837+
},
838+
},
839+
}
840+
err := cfg.Validate()
841+
if err != nil {
842+
t.Errorf("Validate() unexpected error for literal env value: %v", err)
843+
}
844+
}
845+
827846
func TestValidate_ValidCredentialRef(t *testing.T) {
828847
cfg := &Config{
829848
Credentials: map[string]CredentialDef{

internal/config/interpolate.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Package config handles loading and parsing the wrappers.yaml configuration.
2+
package config
3+
4+
import (
5+
"fmt"
6+
"regexp"
7+
)
8+
9+
// credentialNameRe matches valid credential names in {{ name }} templates.
10+
// More restrictive than the existing credentialRefRe: only allows valid credential name chars.
11+
var credentialNameRe = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_-]+)\s*\}\}`)
12+
13+
// FindCredentialRefs extracts all credential reference names from a template string.
14+
// Returns unique names in order of first appearance.
15+
// Example: "prefix:{{ foo }}:{{ bar }}:{{ foo }}" → ["foo", "bar"]
16+
func FindCredentialRefs(value string) []string {
17+
matches := credentialNameRe.FindAllStringSubmatch(value, -1)
18+
if len(matches) == 0 {
19+
return nil
20+
}
21+
22+
seen := make(map[string]bool)
23+
refs := make([]string, 0, len(matches))
24+
for _, m := range matches {
25+
if len(m) > 1 {
26+
name := m[1]
27+
if !seen[name] {
28+
seen[name] = true
29+
refs = append(refs, name)
30+
}
31+
}
32+
}
33+
return refs
34+
}
35+
36+
// HasCredentialRefs returns true if the value contains any {{ name }} templates.
37+
func HasCredentialRefs(value string) bool {
38+
return credentialNameRe.MatchString(value)
39+
}
40+
41+
// Interpolate replaces all {{ name }} templates in value with resolved credentials.
42+
// The resolver function is called for each unique credential name.
43+
// Returns error if any credential resolution fails.
44+
func Interpolate(value string, resolver func(name string) (string, error)) (string, error) {
45+
refs := FindCredentialRefs(value)
46+
if len(refs) == 0 {
47+
return value, nil
48+
}
49+
50+
// Resolve all unique credentials first
51+
resolved := make(map[string]string, len(refs))
52+
for _, name := range refs {
53+
secret, err := resolver(name)
54+
if err != nil {
55+
return "", fmt.Errorf("credential %q: %w", name, err)
56+
}
57+
resolved[name] = secret
58+
}
59+
60+
// Replace all occurrences
61+
result := credentialNameRe.ReplaceAllStringFunc(value, func(match string) string {
62+
// Extract name from match (handles whitespace)
63+
m := credentialNameRe.FindStringSubmatch(match)
64+
if len(m) > 1 {
65+
return resolved[m[1]]
66+
}
67+
return match
68+
})
69+
70+
return result, nil
71+
}
72+
73+
// ClassifyEnvValue determines how an env value should be resolved:
74+
// - "credential": value is an exact credential name → fetch entire value
75+
// - "interpolate": value contains {{ refs }} → interpolate
76+
// - "literal": no credential refs → use as-is
77+
func ClassifyEnvValue(value string, credentialNames map[string]struct{}) string {
78+
// Check exact match first
79+
if _, exists := credentialNames[value]; exists {
80+
return "credential"
81+
}
82+
// Check for template refs
83+
if HasCredentialRefs(value) {
84+
return "interpolate"
85+
}
86+
return "literal"
87+
}
88+
89+
// ValidateEnvRefs validates that all credential references in an env value exist.
90+
// For exact credential matches, checks the value itself.
91+
// For interpolated values, checks all {{ name }} refs.
92+
// Returns list of missing credential names, or nil if all valid.
93+
func ValidateEnvRefs(value string, credentialNames map[string]struct{}) []string {
94+
classification := ClassifyEnvValue(value, credentialNames)
95+
96+
switch classification {
97+
case "credential":
98+
// Already validated by ClassifyEnvValue returning "credential"
99+
return nil
100+
case "interpolate":
101+
refs := FindCredentialRefs(value)
102+
var missing []string
103+
for _, ref := range refs {
104+
if _, exists := credentialNames[ref]; !exists {
105+
missing = append(missing, ref)
106+
}
107+
}
108+
return missing
109+
default:
110+
return nil
111+
}
112+
}
113+
114+
// ResolveEnvValue resolves an env value to its final string.
115+
// Handles all three cases: exact credential, interpolated, and literal.
116+
func ResolveEnvValue(value string, credentialNames map[string]struct{}, resolver func(name string) (string, error)) (string, error) {
117+
classification := ClassifyEnvValue(value, credentialNames)
118+
119+
switch classification {
120+
case "credential":
121+
return resolver(value)
122+
case "interpolate":
123+
return Interpolate(value, resolver)
124+
default:
125+
return value, nil
126+
}
127+
}
128+
129+
// CredentialNamesSet builds a set of credential names from a credentials map.
130+
// Helper to avoid repeatedly building this set.
131+
func CredentialNamesSet(credentials map[string]CredentialDef) map[string]struct{} {
132+
names := make(map[string]struct{}, len(credentials))
133+
for name := range credentials {
134+
names[name] = struct{}{}
135+
}
136+
return names
137+
}
138+

0 commit comments

Comments
 (0)