Skip to content
Merged
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Gatekeeper is a standalone credential-injecting TLS-intercepting proxy. It trans

Key capabilities:

- **Credential injection** — Resolve credentials from environment variables, static values, AWS Secrets Manager, GCP Secret Manager, GCP service account keys, GitHub App keys, or RFC 8693 token exchange, then inject them as HTTP headers for matching hosts
- **Credential injection** — Resolve credentials from environment variables, static values, host command output, AWS Secrets Manager, GCP Secret Manager, GCP service account keys, GitHub App keys, or RFC 8693 token exchange, then inject them as HTTP headers for matching hosts
- **Postgres data plane** — Credential-injecting Postgres proxy on a second listener: routes by TLS SNI, authenticates clients with their run token (sent as the Postgres password inside gatekeeper's TLS), and resolves per-branch Neon passwords (or a static password) on the fly so no database secret lives in the sandbox
- **TLS interception** — MITM proxy with per-host certificate generation from a configured CA
- **MCP relay** — Forward Model Context Protocol requests with credential injection and SSE streaming
Expand Down Expand Up @@ -39,6 +39,7 @@ credentialsource/ Pluggable credential backends
source.go Source interface (CredentialSource, RefreshingSource)
env.go Environment variable source
static.go Literal value source
process.go Host command (credential_process-style) source
awssecretsmanager.go AWS Secrets Manager source
gcpsecretmanager.go GCP Secret Manager source
gcpserviceaccount.go GCP service account OAuth2 token source
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Gatekeeper is a standalone credential-injecting TLS-intercepting proxy. It trans

Gatekeeper is pre-1.0. The configuration schema and credential source interface may change between minor versions.

## Unreleased

### Added

- **`process` credential source** — run a host command (`sh -c`) and use its stdout as the credential value; any helper that prints a credential works (OS keychain CLIs, `pass`, 1Password's `op`, AWS `credential_process` helpers). Implements `RefreshingSource`: when the output is AWS `credential_process`-format JSON (exact-case `Version`/`AccessKeyId`/`Expiration` keys, so unrelated JSON can't hijack the schedule), the credential refreshes on the embedded `Expiration`, and already-expired output fails the fetch (engaging retry backoff) instead of installing credentials that would 401; other output reports a configurable `ttl` (default 5m; gatekeeper re-fetches at the standard 75%-of-TTL schedule). Header-invalid control characters are stripped from the output, with a warning (count only, never the value) when non-whitespace control bytes were present; stderr is included (truncated) in fetch errors for diagnosability, stdout never; the command string is config-owned and must not be accepted from untrusted config by embedding operators ([#38](https://github.com/majorcontext/gatekeeper/pull/38))

## v0.14.1 — 2026-06-23

### Fixed
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ source:
value: "Bearer sk-..."
```

### Process

Runs a host command and uses its stdout as the credential. If the output is AWS `credential_process`-format JSON with an `Expiration`, the credential refreshes automatically on that expiry.

```yaml
source:
type: process
command: "op read op://vault/example/api-key"
```

### AWS Secrets Manager

```yaml
Expand Down
4 changes: 3 additions & 1 deletion config.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ type PostgresCredentialConfig struct {
// fields as delimited strings (like Scopes), never slices, and avoid
// pointer fields, which would compile but silently break deduplication.
type SourceConfig struct {
Type string `yaml:"type"` // "env", "static", "aws-secretsmanager", "gcp-secretmanager", "gcp-service-account", "github-app", "token-exchange"
Type string `yaml:"type"` // "env", "static", "process", "aws-secretsmanager", "gcp-secretmanager", "gcp-service-account", "github-app", "token-exchange"
Var string `yaml:"var,omitempty"` // for env source
Value string `yaml:"value,omitempty"` // for static source
Command string `yaml:"command,omitempty"` // for process source: host command run with `sh -c`
TTL string `yaml:"ttl,omitempty"` // for process source: refresh interval when output has no expiry (Go duration, default 5m)
Secret string `yaml:"secret,omitempty"` // for aws-secretsmanager, gcp-secretmanager; for gcp-service-account, the secret holding the key JSON
Region string `yaml:"region,omitempty"` // for aws-secretsmanager
Project string `yaml:"project,omitempty"` // for gcp-secretmanager, gcp-service-account
Expand Down
38 changes: 29 additions & 9 deletions config_credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gatekeeper
import (
"fmt"
"os"
"time"

"github.com/majorcontext/gatekeeper/credentialsource"
"github.com/majorcontext/gatekeeper/proxy"
Expand All @@ -16,23 +17,42 @@ func ResolveSource(cfg SourceConfig) (credentialsource.CredentialSource, error)
if cfg.Var == "" {
return nil, fmt.Errorf("env source requires 'var' field")
}
if cfg.Value != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
if cfg.Command != "" || cfg.TTL != "" || cfg.Value != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
return nil, fmt.Errorf("env source only uses 'var'; found extraneous fields")
}
return credentialsource.NewEnvSource(cfg.Var), nil
case "process":
if cfg.Command == "" {
return nil, fmt.Errorf("process source requires 'command' field")
}
if cfg.Var != "" || cfg.Value != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
return nil, fmt.Errorf("process source only uses 'command' and 'ttl'; found extraneous fields")
}
var ttl time.Duration
if cfg.TTL != "" {
d, err := time.ParseDuration(cfg.TTL)
if err != nil {
return nil, fmt.Errorf("process source: invalid 'ttl' %q: %w", cfg.TTL, err)
}
if d <= 0 {
return nil, fmt.Errorf("process source: 'ttl' must be positive, got %q", cfg.TTL)
}
ttl = d
}
return credentialsource.NewProcessSource(cfg.Command, ttl), nil
case "static":
if cfg.Value == "" {
return nil, fmt.Errorf("static source requires 'value' field")
}
if cfg.Var != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
if cfg.Var != "" || cfg.Command != "" || cfg.TTL != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
return nil, fmt.Errorf("static source only uses 'value'; found extraneous fields")
}
return credentialsource.NewStaticSource(cfg.Value), nil
case "aws-secretsmanager":
if cfg.Secret == "" {
return nil, fmt.Errorf("aws-secretsmanager source requires 'secret' field")
}
if cfg.Var != "" || cfg.Value != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
if cfg.Var != "" || cfg.Value != "" || cfg.Command != "" || cfg.TTL != "" || cfg.Project != "" || cfg.Version != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
return nil, fmt.Errorf("aws-secretsmanager source only uses 'secret' and 'region'; found extraneous fields")
}
return credentialsource.NewAWSSecretsManagerSource(cfg.Secret, cfg.Region)
Expand All @@ -43,7 +63,7 @@ func ResolveSource(cfg SourceConfig) (credentialsource.CredentialSource, error)
if cfg.Project == "" {
return nil, fmt.Errorf("gcp-secretmanager source requires 'project' field")
}
if cfg.Var != "" || cfg.Value != "" || cfg.Region != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
if cfg.Var != "" || cfg.Value != "" || cfg.Command != "" || cfg.TTL != "" || cfg.Region != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.PrivateKeyPath != "" || cfg.PrivateKeyEnv != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
return nil, fmt.Errorf("gcp-secretmanager source only uses 'secret', 'project', and 'version'; found extraneous fields")
}
return credentialsource.NewGCPSecretManagerSource(cfg.Project, cfg.Secret, cfg.Version)
Expand All @@ -60,7 +80,7 @@ func ResolveSource(cfg SourceConfig) (credentialsource.CredentialSource, error)
if cfg.PrivateKeyPath != "" && cfg.PrivateKeyEnv != "" {
return nil, fmt.Errorf("github-app source: set 'private_key_path' or 'private_key_env', not both")
}
if cfg.Var != "" || cfg.Value != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
if cfg.Var != "" || cfg.Value != "" || cfg.Command != "" || cfg.TTL != "" || cfg.Secret != "" || cfg.Region != "" || cfg.Project != "" || cfg.Version != "" || cfg.Scopes != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
return nil, fmt.Errorf("github-app source only uses 'app_id', 'installation_id', and one of 'private_key_path'/'private_key_env'; found extraneous fields")
}
keyPEM, err := readKeyMaterial(cfg.PrivateKeyPath, cfg.PrivateKeyEnv)
Expand All @@ -87,7 +107,7 @@ func ResolveSource(cfg SourceConfig) (credentialsource.CredentialSource, error)
if cfg.Secret == "" && (cfg.Project != "" || cfg.Version != "") {
return nil, fmt.Errorf("gcp-service-account source only uses 'project' and 'version' with 'secret'")
}
if cfg.Var != "" || cfg.Value != "" || cfg.Region != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
if cfg.Var != "" || cfg.Value != "" || cfg.Command != "" || cfg.TTL != "" || cfg.Region != "" || cfg.AppID != "" || cfg.InstallationID != "" || cfg.Endpoint != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || cfg.ClientSecretEnv != "" || cfg.SubjectHeader != "" || cfg.SubjectFrom != "" || cfg.SubjectTokenType != "" || cfg.Resource != "" || cfg.ActorTokenFrom != "" || cfg.ActorTokenType != "" {
return nil, fmt.Errorf("gcp-service-account source only uses a key location ('private_key_path', 'private_key_env', or 'secret'/'project'/'version') and 'scopes'; found extraneous fields")
}
if cfg.Secret != "" {
Expand Down Expand Up @@ -126,9 +146,9 @@ func readKeyMaterial(path, env string) ([]byte, error) {

// ResolveCredentialSource creates either a static CredentialSource or a dynamic
// CredentialResolver from a credential config. For static sources (env, static,
// aws-secretsmanager, gcp-secretmanager, gcp-service-account, github-app), the
// first return is non-nil. For dynamic sources (token-exchange), the second
// return is non-nil.
// process, aws-secretsmanager, gcp-secretmanager, gcp-service-account,
// github-app), the first return is non-nil. For dynamic sources
// (token-exchange), the second return is non-nil.
func ResolveCredentialSource(cred CredentialConfig) (credentialsource.CredentialSource, proxy.CredentialResolver, error) {
switch cred.Source.Type {
case "token-exchange":
Expand Down
80 changes: 80 additions & 0 deletions config_credential_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"encoding/pem"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

func TestResolveSourceEnv(t *testing.T) {
Expand Down Expand Up @@ -598,6 +600,8 @@ func TestResolveSourceTokenExchangeExtraneousFields(t *testing.T) {
{"with installation_id", SourceConfig{Type: "token-exchange", Endpoint: "http://x", ClientID: "gk", ClientSecretEnv: "TEST_TE_SECRET2", SubjectHeader: "X-S", InstallationID: "extra"}},
{"with private_key_path", SourceConfig{Type: "token-exchange", Endpoint: "http://x", ClientID: "gk", ClientSecretEnv: "TEST_TE_SECRET2", SubjectHeader: "X-S", PrivateKeyPath: "extra"}},
{"with private_key_env", SourceConfig{Type: "token-exchange", Endpoint: "http://x", ClientID: "gk", ClientSecretEnv: "TEST_TE_SECRET2", SubjectHeader: "X-S", PrivateKeyEnv: "extra"}},
{"with command", SourceConfig{Type: "token-exchange", Endpoint: "http://x", ClientID: "gk", ClientSecretEnv: "TEST_TE_SECRET2", SubjectHeader: "X-S", Command: "extra"}},
{"with ttl", SourceConfig{Type: "token-exchange", Endpoint: "http://x", ClientID: "gk", ClientSecretEnv: "TEST_TE_SECRET2", SubjectHeader: "X-S", TTL: "90s"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -611,3 +615,79 @@ func TestResolveSourceTokenExchangeExtraneousFields(t *testing.T) {
})
}
}

func TestResolveSourceProcess(t *testing.T) {
src, err := ResolveSource(SourceConfig{Type: "process", Command: "printf 'proc-token'"})
if err != nil {
t.Fatalf("ResolveSource() error: %v", err)
}
if src.Type() != "process" {
t.Fatalf("Type() = %q, want %q", src.Type(), "process")
}
val, err := src.Fetch(context.Background())
if err != nil {
t.Fatalf("Fetch() error: %v", err)
}
if val != "proc-token" {
t.Fatalf("Fetch() = %q, want %q", val, "proc-token")
}
}

func TestResolveSourceProcessTTL(t *testing.T) {
src, err := ResolveSource(SourceConfig{Type: "process", Command: "printf 'proc-token'", TTL: "90s"})
if err != nil {
t.Fatalf("ResolveSource() error: %v", err)
}
if _, err := src.Fetch(context.Background()); err != nil {
t.Fatalf("Fetch() error: %v", err)
}
rs, ok := src.(interface{ TTL() time.Duration })
if !ok {
t.Fatal("process source should implement RefreshingSource")
}
if got := rs.TTL(); got != 90*time.Second {
t.Fatalf("TTL() = %v, want configured 90s", got)
}
}

func TestResolveSourceProcessInvalidTTL(t *testing.T) {
for _, ttl := range []string{"banana", "-5s", "0s"} {
t.Run(ttl, func(t *testing.T) {
_, err := ResolveSource(SourceConfig{Type: "process", Command: "printf x", TTL: ttl})
if err == nil {
t.Fatalf("expected error for ttl %q, got nil", ttl)
}
if !strings.Contains(err.Error(), "ttl") {
t.Fatalf("error should mention ttl, got: %v", err)
}
})
}
}

func TestResolveSourceEnvRejectsTTLField(t *testing.T) {
_, err := ResolveSource(SourceConfig{Type: "env", Var: "SOME_VAR", TTL: "90s"})
if err == nil {
t.Fatal("expected error: env source must reject the process-only ttl field")
}
}

func TestResolveSourceProcessMissingCommand(t *testing.T) {
_, err := ResolveSource(SourceConfig{Type: "process"})
if err == nil {
t.Fatal("expected error for missing command field, got nil")
}
}

func TestResolveSourceProcessExtraneousField(t *testing.T) {
_, err := ResolveSource(SourceConfig{Type: "process", Command: "printf x", Var: "SOME_VAR"})
if err == nil {
t.Fatal("expected error for extraneous field on process source, got nil")
}
}

func TestResolveSourceEnvRejectsCommandField(t *testing.T) {
_, err := ResolveSource(SourceConfig{Type: "env", Var: "SOME_VAR", Command: "printf x"})
if err == nil {
t.Fatal("expected error: env source must reject the process-only command field")
}
}
Loading
Loading