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
10 changes: 10 additions & 0 deletions docs/extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,16 @@ Secrets are split across two `spec.yaml` sections:
describe how the gateway injects that credential as an HTTP header when it
proxies requests to those hosts. The service-id in `serviceAuth` and
`serviceDomains` is the same key used under `credentials.sources`.
- `network.serviceAuth.<service-id>.hostsFromCredential` names another
`credentials.sources` id whose resolved value is a host, for services that
can point at a self-hosted instance. When that credential resolves, its value
**replaces** the declared `hosts` for this service — it is not additive,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It replaces the union of hosts and the serviceDomains-derived hosts, not just the declared hosts: both go into the same list in buildSecrets (

hostsByService := map[string][]string{}
if doc.Network != nil {
for host, id := range doc.Network.ServiceDomains {
hostsByService[id] = append(hostsByService[id], host)
}
for id, auth := range doc.Network.ServiceAuth {
hostsByService[id] = append(hostsByService[id], auth.Hosts...)
}
for id := range hostsByService {
hostsByService[id] = sortDedupeStrings(hostsByService[id])
). Matters for specs like github-cli that express hosts via serviceDomains.

since an instance-specific token must not be released to the public default
host as well. The replacement host joins the allow set like a declared one,
so one env var is enough to make the tool reach the instance with token
injection working. An unset credential keeps the declared hosts; a value that
is not a usable host warns and is ignored. Values may be a bare host,
`host:port`, or a full URL — the scheme, port and path are stripped.

The placeholder convention is Go `fmt`-style `%s`, not `{secret}`. An empty
`valueFormat` means "inject the raw secret value" with no wrapping (see
Expand Down
25 changes: 25 additions & 0 deletions extensions/features/gitlab-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,28 @@ requests from the command line. Opt-in (disabled by default).
## Installation

Downloads the latest `.deb` release from GitLab's release API. Requires root.

## Auth

`GITLAB_TOKEN`/`GITLAB_ACCESS_TOKEN`, `OAUTH_TOKEN` and
`JOB_TOKEN`/`CI_JOB_TOKEN` are resolved from the host environment, the layered
secrets files, or the persisted env store, and injected as env vars. `spec.yaml`
maps each to the header the gateway injects.

## Self-hosted instances

Set `GITLAB_HOST` to point `glab` at a self-hosted instance:

```bash
GITLAB_HOST=gitlab.example.com enclave --features +gitlab-cli
```

That single env var selects the instance for `glab`, retargets token injection,
and joins the network allowlist — no spec edit or `allow_domains` entry. See

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True at session start, but enclave network apply recomputes the desired bundle from the tool profile only, with no features and no runtime overrides (

func (r EffectiveResolver) loadSpecDomains(tool string) (allowed []string, denied []string, err error) {
profile, err := config.LoadProfile(r.paths, tool)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil, nil
}
return nil, nil, fmt.Errorf("load spec domains for %q: %w", tool, err)
}
allowed = append(append([]string(nil), profile.AllowedDomains...), model.ReleaseHosts(profile.Secrets)...)
return allowed, profile.DeniedDomains, nil
), then rewrites it (
applyErr = bundle.WriteConfigBundle(bundle.BundleWriteConfig{
Dir: expectedBundleDir,
Policy: resolved.Effective,
Tool: target.Tool,
})
). So network status reports drift and an apply cuts the self-hosted host out of a running session. The gap already exists for gitlab.com, but a follow-up issue would be good since the override makes it the only host the token can reach.

`serviceAuth.hostsFromCredential` in `docs/extensions/README.md` for the
accepted value forms and why the host replaces the `gitlab.com` defaults instead
of adding to them.

To set it persistently, put it in a secrets file rather than the shell — global
in `~/.local/state/enclave/secrets/global.env`, or per project under
`~/.local/state/enclave/projects/<hash>/`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong path. Per-project secrets live at ~/.local/state/enclave/secrets/projects/<hash>/<tool>.env (

enclave/docs/auth.md

Lines 136 to 138 in 5e80820

| 1 | `~/.local/state/enclave/secrets/global.env` | All agents, all projects |
| 2 | `~/.local/state/enclave/secrets/global/<tool>.env` | Specific agent, all projects |
| 3 | `~/.local/state/enclave/secrets/projects/<hash>/<tool>.env` | Specific agent, specific project |
), and that file is keyed by the tool (e.g. claude.env), not by the feature, which is worth spelling out since this is a mixin.

9 changes: 6 additions & 3 deletions extensions/features/gitlab-cli/spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ credentials:
gitlab-token: { env: [GITLAB_TOKEN, GITLAB_ACCESS_TOKEN] }
gitlab-oauth-token: { env: [OAUTH_TOKEN] }
gitlab-job-token: { env: [JOB_TOKEN, CI_JOB_TOKEN] }
# Not a credential: glab reads GITLAB_HOST to pick the instance, and
# hostsFromCredential below retargets token injection at it.
gitlab-host: { env: [GITLAB_HOST], apiKey: false }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this is a declared credential, the resolved GITLAB_HOST is written to the persisted env store under the default --persist, so later runs without the env var keep the self-hosted override (and keep gitlab.com out of the release hosts). That contradicts "An unset credential keeps the declared hosts" in the docs, so either document how to clear it or skip persistence for non-apiKey host credentials.

network:
serviceAuth:
gitlab-token: { headerName: private-token, hosts: [gitlab.com, "*.gitlab.com"] }
gitlab-oauth-token: { headerName: authorization, valueFormat: "Bearer %s", hosts: [gitlab.com, "*.gitlab.com"] }
gitlab-job-token: { headerName: job-token, hosts: [gitlab.com, "*.gitlab.com"] }
gitlab-token: { headerName: private-token, hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host }
gitlab-oauth-token: { headerName: authorization, valueFormat: "Bearer %s", hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host }
gitlab-job-token: { headerName: job-token, hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host }
7 changes: 4 additions & 3 deletions internal/config/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,10 @@ func normalizeSecretRelease(secretID string, release *model.SecretReleaseConfig)

return &model.SecretReleaseConfig{
HTTP: &model.HTTPSecretReleaseConfig{
Hosts: hosts,
Header: header,
Format: format,
Hosts: hosts,
Header: header,
Format: format,
HostsFromSecret: strings.TrimSpace(release.HTTP.HostsFromSecret),
},
}, nil
}
Expand Down
5 changes: 5 additions & 0 deletions internal/config/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ type specServiceAuth struct {
// these hosts are unioned with any serviceDomains entries pointing at this
// service id.
Hosts []string `json:"hosts,omitempty"`
// HostsFromCredential names a credentials.sources id whose resolved value
// is a host. When that credential resolves at runtime, its value replaces
// Hosts for this service, so a self-hosted instance can be selected by
// setting one env var instead of editing the spec.
HostsFromCredential string `json:"hostsFromCredential,omitempty"`
}

type specEnvironment struct {
Expand Down
17 changes: 13 additions & 4 deletions internal/config/spec_map.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,18 @@ func validateServiceAuthMappings(doc specDocument, specPath string) error {
sources[id] = struct{}{}
}
}
for id := range doc.Network.ServiceAuth {
for id, auth := range doc.Network.ServiceAuth {
if _, ok := sources[id]; !ok {
return fmt.Errorf("%s: network.serviceAuth[%q] has no matching credentials.sources entry", specPath, id)
}
if auth.HostsFromCredential == "" {
continue
}
// Same typo class as the service ids above: an unmatched reference
// would silently leave the service pinned to its static hosts.
if _, ok := sources[auth.HostsFromCredential]; !ok {
return fmt.Errorf("%s: network.serviceAuth[%q].hostsFromCredential references credential %q with no matching credentials.sources entry", specPath, id, auth.HostsFromCredential)
}
}
for host, id := range doc.Network.ServiceDomains {
if _, ok := sources[id]; !ok {
Expand Down Expand Up @@ -139,9 +147,10 @@ func buildSecrets(doc specDocument) map[string]model.SecretConfig {
if auth, ok := doc.Network.ServiceAuth[id]; ok {
sc.Release = &model.SecretReleaseConfig{
HTTP: &model.HTTPSecretReleaseConfig{
Hosts: hostsByService[id],
Header: auth.HeaderName,
Format: auth.ValueFormat,
Hosts: hostsByService[id],
Header: auth.HeaderName,
Format: auth.ValueFormat,
HostsFromSecret: auth.HostsFromCredential,
},
}
}
Expand Down
40 changes: 40 additions & 0 deletions internal/config/spec_map_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,46 @@ network:
}
})

t.Run("unmatched hostsFromCredential fails", func(t *testing.T) {
doc := mustDoc(t, `
schemaVersion: "1"
kind: mixin
name: gitlab-cli
credentials:
sources:
gitlab-token: { env: [GITLAB_TOKEN] }
gitlab-host: { env: [GITLAB_HOST], apiKey: false }
network:
serviceAuth:
gitlab-token: { headerName: private-token, hostsFromCredential: gitlab-hsot }
`)
if err := validateServiceAuthMappings(doc, "gitlab-cli/spec.yaml"); err == nil {
t.Fatal("expected error for hostsFromCredential with no matching credentials.sources entry")
}
})

t.Run("matching hostsFromCredential passes", func(t *testing.T) {
doc := mustDoc(t, `
schemaVersion: "1"
kind: mixin
name: gitlab-cli
credentials:
sources:
gitlab-token: { env: [GITLAB_TOKEN] }
gitlab-host: { env: [GITLAB_HOST], apiKey: false }
network:
serviceAuth:
gitlab-token: { headerName: private-token, hosts: [gitlab.com], hostsFromCredential: gitlab-host }
`)
if err := validateServiceAuthMappings(doc, "gitlab-cli/spec.yaml"); err != nil {
t.Fatalf("validateServiceAuthMappings() error = %v, want nil", err)
}
secrets := buildSecrets(doc)
if got := secrets["gitlab-token"].Release.HTTP.HostsFromSecret; got != "gitlab-host" {
t.Fatalf("HostsFromSecret = %q, want %q", got, "gitlab-host")
}
})

t.Run("matching ids pass", func(t *testing.T) {
doc := mustDoc(t, `
schemaVersion: "1"
Expand Down
15 changes: 12 additions & 3 deletions internal/config/testdata/golden/feature-gitlab-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
"needs_root": true,
"priority": 50,
"secrets": {
"gitlab-host": {
"env_vars": [
"GITLAB_HOST"
],
"api_key": false
},
"gitlab-job-token": {
"env_vars": [
"JOB_TOKEN",
Expand All @@ -16,7 +22,8 @@
"*.gitlab.com",
"gitlab.com"
],
"header": "job-token"
"header": "job-token",
"hosts_from_secret": "gitlab-host"
}
}
},
Expand All @@ -31,7 +38,8 @@
"gitlab.com"
],
"header": "authorization",
"format": "Bearer %s"
"format": "Bearer %s",
"hosts_from_secret": "gitlab-host"
}
}
},
Expand All @@ -46,7 +54,8 @@
"*.gitlab.com",
"gitlab.com"
],
"header": "private-token"
"header": "private-token",
"hosts_from_secret": "gitlab-host"
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions internal/model/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ type HTTPSecretReleaseConfig struct {
Hosts []string `json:"hosts"`
Header string `json:"header"`
Format string `json:"format,omitempty"`
// HostsFromSecret names another secret id whose resolved value is a host.
// When that secret resolves, its value replaces Hosts for this release
// rule; see spec.yaml network.serviceAuth.hostsFromCredential.
HostsFromSecret string `json:"hosts_from_secret,omitempty"`
}

// ReleaseHosts returns the sorted, deduplicated HTTP release hosts declared
Expand Down
76 changes: 76 additions & 0 deletions internal/runtime/active_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import (
"fmt"
"os"
"sort"
"strings"

"enclave/internal/domainpattern"
"enclave/internal/logx"
"enclave/internal/model"
"enclave/internal/secretfile"
)
Expand Down Expand Up @@ -163,6 +166,79 @@ func resolveEnvAliasValue(secret activeSecret, layeredSecrets map[string]string,
return chosen.value, chosen.source, true, nil
}

// resolveReleaseHostOverrides resolves the credentials named by each release
// rule's HostsFromSecret and returns secret-id -> replacement hosts. A service
// whose host credential is unset or unusable keeps its declared hosts, so the
// default (e.g. gitlab.com) still applies.
//
// The replacement is deliberately not additive: the referenced credential names
// the one instance the token belongs to, and injecting an instance-specific
// token into the default hosts as well would expose it to a service that cannot
// accept it.
func resolveReleaseHostOverrides(secrets []activeSecret, hostHome string, layeredSecrets map[string]string, persistedEnv map[string]string) map[string][]string {
// Several services commonly share one host credential (gitlab's three
// tokens), so group by the reference and resolve each credential once.
byID := make(map[string]activeSecret, len(secrets))
referencedBy := map[string][]string{}
for _, secret := range secrets {
byID[secret.ID] = secret
if secret.ReleaseHTTP != nil && secret.ReleaseHTTP.HostsFromSecret != "" {
ref := secret.ReleaseHTTP.HostsFromSecret
referencedBy[ref] = append(referencedBy[ref], secret.ID)
}
}

overrides := map[string][]string{}
for ref, secretIDs := range referencedBy {
source, ok := byID[ref]
if !ok {
continue
}
value, _, found, err := resolveActiveSecretValue(source, hostHome, layeredSecrets, persistedEnv)
if err != nil {
logx.Warnf("Cannot resolve host credential %s (%v); keeping declared hosts.", ref, err)
continue
}
if !found {
continue
}
host := normalizeReleaseHost(ref, value)
if host == "" {
continue
}
for _, id := range secretIDs {
overrides[id] = []string{host}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A successful override is completely silent, and the injection debug log redacts the value, so there is no way to see which host was picked. An info line here would help, similar to how ad-hoc allowlisted domains are reported:

logx.Infof("Allowlisted ad-hoc domains: %s", strings.Join(m.run.AllowDomains, ", "))

}
}
return overrides
}

// normalizeReleaseHost turns a credential value into an allowlist-comparable
// host, tolerating the URL forms CLIs accept (glab reads GITLAB_HOST as either
// a bare host or a full URL). An unusable value warns and is dropped rather
// than failing the session, since the declared hosts remain valid.
func normalizeReleaseHost(secretID string, value string) string {
trimmed := value
if index := strings.Index(trimmed, "://"); index >= 0 {
trimmed = trimmed[index+len("://"):]
}
if index := strings.IndexAny(trimmed, "/?#"); index >= 0 {
trimmed = trimmed[:index]
}
// NormalizeHost strips the port and IPv6 brackets but does not validate the
// labels, so the pattern validator runs after it to reject junk that would
// otherwise become a bogus allowlist entry.
host, err := domainpattern.NormalizeHost(trimmed)
if err == nil {
host, err = domainpattern.Normalize(host)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

domainpattern.Normalize accepts wildcard patterns, so GITLAB_HOST=*.example.com becomes a wildcard release target and allowlist entry. That works against the "names the one instance the token belongs to" rationale, so rejecting * here seems right.

}
if err != nil {
logx.Warnf("Secret %s: value %q is not a usable host (%v); keeping declared hosts.", secretID, value, err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prints the credential value verbatim at warn level. If hostsFromCredential ever points at a token (or GITLAB_HOST gets a token value by mistake), the secret lands in the terminal. Everywhere else secret-derived values go through util.RedactSecret, and at debug level:

logx.Debugf("Injected %s from %s (%s)", envVar, secretSource, util.RedactSecret(envValue))

return ""
}
return host
}

// resolveFileSecretValue reads the secret's file source, if any. A missing file
// (or an empty resolved value) reports found=false so the caller can fall back
// to the env aliases; a malformed parser or file content fails loudly.
Expand Down
14 changes: 13 additions & 1 deletion internal/runtime/auth_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ func (m authManager) injectDeclaredSecrets(hooks auth.Hooks, authCtx auth.Contex
for _, secret := range suppressedActiveSecrets {
logx.Debugf("Suppressed declared API key secret %s due to %s", secret.ID, suppressionReason)
}
// Must precede shouldUseSecretReleases: that call resolves and caches the
// effective policy, which unions the release hosts into the allow set.
m.releaseHostOverrides = resolveReleaseHostOverrides(eligibleSecrets, m.host.Home, layeredSecrets, persistedEnv)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The invariant is enforced only by this comment, and resolveEffectivePolicy memoizes on the Runtime (

func (r *Runtime) resolveEffectivePolicy() (policy.ResolveResult, error) {
if r.policyResolved {
return r.policyResult, r.policyErr
}
r.policyResolved = true
). It holds today, but a future policy lookup anywhere in prepareMounts (which runs before prepareVolumes) would silently cache an allow set without the override while the token still targets it. Either reset policyResolved here when overrides are non-empty, or fail loudly if the policy is already resolved.

secretReleaseEnabled := m.shouldUseSecretReleases(eligibleSecrets)
for _, secret := range eligibleSecrets {
secretValue, secretSource, found, err := resolveActiveSecretValue(secret, m.host.Home, layeredSecrets, persistedEnv)
Expand All @@ -190,7 +193,7 @@ func (m authManager) injectDeclaredSecrets(hooks auth.Hooks, authCtx auth.Contex
SecretID: secret.ID,
Placeholder: placeholder,
Value: secretValue,
Hosts: append([]string{}, secret.ReleaseHTTP.Hosts...),
Hosts: m.releaseHostsFor(secret),
Header: secret.ReleaseHTTP.Header,
Format: secret.ReleaseHTTP.Format,
})
Expand Down Expand Up @@ -414,6 +417,15 @@ func (m authManager) apiKeySecretSuppressionReason() string {
return ""
}

// releaseHostsFor returns the hosts a secret's release rule applies to, after
// any serviceAuth.hostsFromCredential replacement.
func (m authManager) releaseHostsFor(secret activeSecret) []string {
if hosts, ok := m.releaseHostOverrides[secret.ID]; ok {
return append([]string{}, hosts...)
}
return append([]string{}, secret.ReleaseHTTP.Hosts...)
}

func (m authManager) shouldUseSecretReleases(activeSecrets []activeSecret) bool {
hasHTTPRelease := false
for _, secret := range activeSecrets {
Expand Down
7 changes: 4 additions & 3 deletions internal/runtime/auth_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -824,9 +824,10 @@ func secretConfig(envVars []string, release *model.HTTPSecretReleaseConfig) mode
if release != nil {
cfg.Release = &model.SecretReleaseConfig{
HTTP: &model.HTTPSecretReleaseConfig{
Hosts: append([]string{}, release.Hosts...),
Header: release.Header,
Format: release.Format,
Hosts: append([]string{}, release.Hosts...),
Header: release.Header,
Format: release.Format,
HostsFromSecret: release.HostsFromSecret,
},
}
}
Expand Down
Loading
Loading