Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0beff8f
feat(agentproxy): add local coupled mode to the proxy engine
saifsmailbox98 Jul 22, 2026
6e236f8
feat(sandbox): add OS sandbox (macOS Seatbelt, Linux bubblewrap)
saifsmailbox98 Jul 22, 2026
1cbeeb2
feat(cmd): add `agent-proxy run` local sandboxed mode command
saifsmailbox98 Jul 22, 2026
37aabd0
fix(agent-proxy): address review findings in local run mode
saifsmailbox98 Jul 23, 2026
fa90198
chore(agent-proxy): trim comments to house style, drop dead code
saifsmailbox98 Jul 23, 2026
eb23884
fix(agent-proxy): keep run's proxy activity logs off the interactive …
saifsmailbox98 Jul 23, 2026
42a3453
feat(agent-proxy): broker native-trust tools on macOS via a persisten…
saifsmailbox98 Jul 23, 2026
97a1a93
fix(agent-proxy): harden Linux sandbox credential masking
saifsmailbox98 Jul 24, 2026
12add4c
fix(agent-proxy): fix hard-fence supervisor loopback and signal forwa…
saifsmailbox98 Jul 24, 2026
c1434e1
fix(agent-proxy): quiet `agent-proxy run` startup output
saifsmailbox98 Jul 24, 2026
0b76f49
fix(agent-proxy): fail closed if the control-plane host can't be derived
saifsmailbox98 Jul 25, 2026
970f187
merge: incorporate main (agent-proxy usage reporting #322, connect co…
saifsmailbox98 Jul 25, 2026
917e4f7
improvement(agent-proxy): resolve run --env/--path like connect
saifsmailbox98 Jul 25, 2026
6ddcc52
fix(agent-proxy): address PR review findings
saifsmailbox98 Jul 25, 2026
e469e81
refactor(agent-proxy): tighten sandbox structure and fix four defects
saifsmailbox98 Jul 27, 2026
a00a445
feat(agent-proxy): broker dynamic secrets in local `run` mode
saifsmailbox98 Jul 27, 2026
aeb90c9
docs(agent-proxy): tighten the comments added by this PR
saifsmailbox98 Jul 28, 2026
cedbcf3
chore(agent-proxy): final audit pass over the PR's files
saifsmailbox98 Jul 28, 2026
4f27353
Merge remote-tracking branch 'origin/main' into saif/age2-36-add-loca…
saifsmailbox98 Jul 28, 2026
8818da5
chore(agent-proxy): condense the densest comment to match main's winr…
saifsmailbox98 Jul 28, 2026
20b305d
fix(agent-proxy): close a terminal-injection escape and two credentia…
saifsmailbox98 Jul 28, 2026
df4b7d8
fix(agent-proxy): drop "v1" from the unsupported-platform message
saifsmailbox98 Jul 28, 2026
f04d061
improvement(agent-proxy): align run's user-facing text with the rest …
saifsmailbox98 Jul 28, 2026
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
github.com/fatih/semgroup v1.2.0
github.com/gitleaks/go-gitdiff v0.9.1
github.com/go-mysql-org/go-mysql v1.13.0
github.com/gofrs/flock v0.8.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/h2non/filetype v1.1.3
Expand Down Expand Up @@ -111,7 +112,6 @@ require (
github.com/go-openapi/swag v0.23.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.5 // indirect
Expand Down
183 changes: 0 additions & 183 deletions go.sum

Large diffs are not rendered by default.

100 changes: 87 additions & 13 deletions packages/agentproxy/ca.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,21 @@ const (
leafTTL = 24 * time.Hour
leafReuseMargin = 1 * time.Hour
maxLeafCacheEntries = 8192

// localRootTTL bounds the in-memory self-signed local root; just needs to outlast one session.
localRootTTL = 7 * 24 * time.Hour
)

type caManager struct {
token func() string

// local: the intermediate fields hold a self-signed root minted at construction, never re-signed.
local bool

mu sync.Mutex
intermediateKey *ecdsa.PrivateKey
intermediateCert *x509.Certificate
intermediateExp time.Time
signingKey *ecdsa.PrivateKey
signingCert *x509.Certificate
signingExp time.Time
lastResignAttempt time.Time
resignGen atomic.Uint64

Expand All @@ -55,16 +61,84 @@ func newCaManager(token func() string) *caManager {
}
}

func (c *caManager) ensureIntermediate() error {
// generateLocalRoot mints a self-signed ECDSA P-256 root (P-256 so rustls-based agents accept it).
func generateLocalRoot() (*ecdsa.PrivateKey, *x509.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate local root CA key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, err
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "Infisical Agent Proxy Local Root CA"},
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: time.Now().Add(localRootTTL),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLenZero: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, nil, fmt.Errorf("failed to self-sign local root CA: %w", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse local root CA certificate: %w", err)
}
return key, cert, nil
}

// caManagerFromRoot wraps an existing local root key/cert as a caManager.
func caManagerFromRoot(key *ecdsa.PrivateKey, cert *x509.Certificate) *caManager {
return &caManager{
local: true,
signingKey: key,
signingCert: cert,
signingExp: cert.NotAfter,
leafCache: make(map[string]*leafEntry),
}
}

// newLocalCaManager builds a fresh in-memory local root. The private key never leaves memory.
func newLocalCaManager() (*caManager, error) {
key, cert, err := generateLocalRoot()
if err != nil {
return nil, err
}
return caManagerFromRoot(key, cert), nil
}

// RootPEM returns the local root's public certificate (nil outside local mode).
func (c *caManager) RootPEM() []byte {
if !c.local {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.signingCert == nil {
return nil
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.signingCert.Raw})
}

func (c *caManager) ensureSigningCert() error {
// The local root is minted once at construction and is never re-signed.
if c.local {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()

remaining := time.Until(c.intermediateExp)
if c.intermediateCert != nil && remaining > intermediateRenewThreshold {
remaining := time.Until(c.signingExp)
if c.signingCert != nil && remaining > intermediateRenewThreshold {
return nil
}

canFallBack := c.intermediateCert != nil && remaining > intermediateFallbackMargin
canFallBack := c.signingCert != nil && remaining > intermediateFallbackMargin
if canFallBack && time.Since(c.lastResignAttempt) < intermediateRetryInterval {
return nil
}
Expand Down Expand Up @@ -109,9 +183,9 @@ func (c *caManager) resignIntermediateLocked() error {
return fmt.Errorf("failed to parse intermediate CA certificate: %w", err)
}

c.intermediateKey = key
c.intermediateCert = cert
c.intermediateExp = cert.NotAfter
c.signingKey = key
c.signingCert = cert
c.signingExp = cert.NotAfter
c.resignGen.Add(1)
c.leafMu.Lock()
c.leafCache = make(map[string]*leafEntry)
Expand All @@ -121,7 +195,7 @@ func (c *caManager) resignIntermediateLocked() error {
}

func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) {
if err := c.ensureIntermediate(); err != nil {
if err := c.ensureSigningCert(); err != nil {
return tls.Certificate{}, err
}

Expand All @@ -133,8 +207,8 @@ func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) {
c.leafMu.Unlock()

c.mu.Lock()
interKey := c.intermediateKey
interCert := c.intermediateCert
interKey := c.signingKey
interCert := c.signingCert
gen := c.resignGen.Load()
c.mu.Unlock()

Expand Down
10 changes: 5 additions & 5 deletions packages/agentproxy/ca_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ func installTestIntermediate(t *testing.T, c *caManager, notAfter time.Time) {
if err != nil {
t.Fatal(err)
}
c.intermediateKey = key
c.intermediateCert = cert
c.intermediateExp = cert.NotAfter
c.signingKey = key
c.signingCert = cert
c.signingExp = cert.NotAfter
}

func TestEnsureIntermediateFallsBackWhenResignFails(t *testing.T) {
Expand All @@ -55,7 +55,7 @@ func TestEnsureIntermediateFallsBackWhenResignFails(t *testing.T) {
c := newCaManager(func() string { return "test-token" })
installTestIntermediate(t, c, time.Now().Add(1*time.Hour))

if err := c.ensureIntermediate(); err != nil {
if err := c.ensureSigningCert(); err != nil {
t.Fatalf("expected fallback to the valid intermediate, got error: %v", err)
}
leaf, err := c.mintLeaf("api.example.com")
Expand All @@ -77,7 +77,7 @@ func TestEnsureIntermediateFailsWithoutFallback(t *testing.T) {
defer func() { config.INFISICAL_URL = origURL }()

c := newCaManager(func() string { return "test-token" })
if err := c.ensureIntermediate(); err == nil {
if err := c.ensureSigningCert(); err == nil {
t.Fatal("expected an error when there is no intermediate to fall back to")
}
}
67 changes: 50 additions & 17 deletions packages/agentproxy/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,37 @@ func (a *agentCache) evictIfFullLocked(incoming string) {
}

func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService, error) {
agentClient := resty.New().SetAuthToken(jwt)
listResp, err := api.CallListProxiedServices(agentClient, api.ListProxiedServicesRequest{
return resolveServices(scope, resolveParams{
discoveryToken: jwt,
valueToken: a.proxyToken,
includeNonProxyable: false,
registerDynamic: func(cred api.ProxiedServiceCredential, projectSlug string) *dynamicCredentialRef {
key := leaseKey{
jwt: jwt,
scope: scope,
secretName: cred.DynamicSecretName,
configHash: canonicalConfigHash(cred.DynamicSecretConfig),
}
a.leases.register(key, leaseSpec{projectSlug: projectSlug, config: cred.DynamicSecretConfig})
return &dynamicCredentialRef{key: key, field: cred.DynamicSecretField}
},
})
}

// resolveParams holds what differs between the remote (agentCache) and local (localResolver) resolvers.
type resolveParams struct {
discoveryToken string
valueToken func() string
includeNonProxyable bool
// registerDynamic maps a dynamic-secret credential to its lease ref, or returns nil to skip it.
registerDynamic func(cred api.ProxiedServiceCredential, projectSlug string) *dynamicCredentialRef
}

// resolveServices lists the proxied services for a scope and attaches credential values. Shared by
// both resolvers; the differences live in resolveParams.
func resolveServices(scope agentScope, p resolveParams) ([]*resolvedService, error) {
client := resty.New().SetAuthToken(p.discoveryToken)
listResp, err := api.CallListProxiedServices(client, api.ListProxiedServicesRequest{
ProjectID: scope.projectID,
Environment: scope.environment,
SecretPath: scope.secretPath,
Expand All @@ -212,11 +241,11 @@ func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService,
return nil, fmt.Errorf("failed to discover proxied services: %w", err)
}

secretValues := a.fetchSecretValues(scope, referencedStaticKeys(listResp.Services))
secretValues := fetchSecretValues(p.valueToken, scope, referencedStaticKeys(listResp.Services, p.includeNonProxyable))

var services []*resolvedService
for _, svc := range listResp.Services {
if !svc.CanProxy {
if !p.includeNonProxyable && !svc.CanProxy {
continue
}
rs := &resolvedService{
Expand All @@ -227,21 +256,18 @@ func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService,
}
for _, cred := range svc.Credentials {
if cred.DynamicSecretName != "" {
key := leaseKey{
jwt: jwt,
scope: scope,
secretName: cred.DynamicSecretName,
configHash: canonicalConfigHash(cred.DynamicSecretConfig),
ref := p.registerDynamic(cred, listResp.ProjectSlug)
if ref == nil {
continue
}
a.leases.register(key, leaseSpec{projectSlug: listResp.ProjectSlug, config: cred.DynamicSecretConfig})
rs.credentials = append(rs.credentials, resolvedCredential{
role: cred.Role,
headerName: cred.HeaderName,
headerPrefix: cred.HeaderPrefix,
headerPurpose: cred.HeaderPurpose,
placeholder: cred.PlaceholderValue,
surfaces: cred.SubstitutionSurfaces,
dynamic: &dynamicCredentialRef{key: key, field: cred.DynamicSecretField},
dynamic: ref,
})
continue
}
Expand Down Expand Up @@ -270,11 +296,11 @@ func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService,
return services, nil
}

func referencedStaticKeys(services []api.ProxiedService) []string {
func referencedStaticKeys(services []api.ProxiedService, includeNonProxyable bool) []string {
seen := make(map[string]struct{})
var keys []string
for _, svc := range services {
if !svc.CanProxy {
if !includeNonProxyable && !svc.CanProxy {
continue
}
for _, cred := range svc.Credentials {
Expand All @@ -291,12 +317,12 @@ func referencedStaticKeys(services []api.ProxiedService) []string {
return keys
}

// fetchSecretValues resolves referenced static secrets individually so a key the proxy can't read is
// skipped rather than failing the whole agent.
func (a *agentCache) fetchSecretValues(scope agentScope, keys []string) map[string]string {
// fetchSecretValues resolves referenced static secrets individually so a key the value identity can't
// read is skipped rather than failing the whole agent.
func fetchSecretValues(token func() string, scope agentScope, keys []string) map[string]string {
values := make(map[string]string, len(keys))
for _, key := range keys {
secret, _, err := util.GetSinglePlainTextSecretByNameV3(a.proxyToken(), scope.projectID, scope.environment, scope.secretPath, key)
secret, _, err := util.GetSinglePlainTextSecretByNameV3(token(), scope.projectID, scope.environment, scope.secretPath, key)
if err != nil {
log.Warn().Err(err).Msgf("agent proxy: skipping static secret %q; proxy identity cannot read it", key)
continue
Expand All @@ -306,6 +332,13 @@ func (a *agentCache) fetchSecretValues(scope agentScope, keys []string) map[stri
return values
}

// close drops every cached entry so credential values become unreachable. Called at proxy shutdown.
func (a *agentCache) close() {
a.mu.Lock()
defer a.mu.Unlock()
a.entries = make(map[string]*agentEntry)
}

func (a *agentCache) refreshActive() {
type refreshTarget struct {
key string
Expand Down
6 changes: 3 additions & 3 deletions packages/agentproxy/connect_live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ func TestConnectTunnelLiveOverTCP(t *testing.T) {
upstreamPool := x509.NewCertPool()
upstreamPool.AddCert(upstream.Certificate())
ps := &proxyServer{
opts: Options{UnmatchedHost: UnmatchedAllow},
ca: ca,
cache: cache,
opts: Options{UnmatchedHost: UnmatchedAllow},
ca: ca,
resolver: cache,
transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: upstreamPool},
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
Expand Down
14 changes: 7 additions & 7 deletions packages/agentproxy/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
)

// newTestCA builds a caManager with a locally self-signed intermediate so mintLeaf works offline
// (ensureIntermediate skips the API when an unexpired intermediate is already set). Returns the
// (ensureSigningCert skips the API when an unexpired intermediate is already set). Returns the
// intermediate cert so the client can trust the minted leaf chain.
func newTestCA(t *testing.T) (*caManager, *x509.Certificate) {
t.Helper()
Expand All @@ -46,10 +46,10 @@ func newTestCA(t *testing.T) (*caManager, *x509.Certificate) {
t.Fatal(err)
}
return &caManager{
intermediateKey: key,
intermediateCert: cert,
intermediateExp: cert.NotAfter,
leafCache: make(map[string]*leafEntry),
signingKey: key,
signingCert: cert,
signingExp: cert.NotAfter,
leafCache: make(map[string]*leafEntry),
}, cert
}

Expand Down Expand Up @@ -98,7 +98,7 @@ func TestConnectTunnelInjectsCredentialsAndKeepsAlive(t *testing.T) {
ps := &proxyServer{
opts: Options{UnmatchedHost: UnmatchedAllow},
ca: ca,
cache: cache,
resolver: cache,
transport: stub,
}

Expand Down Expand Up @@ -175,7 +175,7 @@ func TestConnectRequiresProxyAuth(t *testing.T) {
ps := &proxyServer{
opts: Options{UnmatchedHost: UnmatchedAllow},
ca: ca,
cache: cache,
resolver: cache,
transport: &stubRoundTripper{},
}

Expand Down
4 changes: 2 additions & 2 deletions packages/agentproxy/forward_activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func newRecordingProxy(t *testing.T, unmatchedHost, jwt string, scope agentScope
}
ps := &proxyServer{
opts: Options{UnmatchedHost: unmatchedHost},
cache: cache,
resolver: cache,
leases: newLeaseStore(func() string { return "" }),
transport: &http.Transport{},
}
Expand Down Expand Up @@ -115,7 +115,7 @@ func TestForwardCapturesIdentityForRecord(t *testing.T) {
}
ps := &proxyServer{
opts: Options{UnmatchedHost: UnmatchedAllow},
cache: cache,
resolver: cache,
leases: newLeaseStore(func() string { return "" }),
transport: reflectingTransport{header: make(http.Header)},
}
Expand Down
Loading
Loading