diff --git a/go.mod b/go.mod index d0568db8..bc4dc53d 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/go-ldap/ldap/v3 v3.4.14 github.com/go-mysql-org/go-mysql v1.13.0 github.com/go-sql-driver/mysql v1.7.1 + 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 @@ -115,7 +116,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-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect diff --git a/packages/agentproxy/ca.go b/packages/agentproxy/ca.go index 6c56502a..a05af03d 100644 --- a/packages/agentproxy/ca.go +++ b/packages/agentproxy/ca.go @@ -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 @@ -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 } @@ -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) @@ -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 } @@ -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() diff --git a/packages/agentproxy/ca_test.go b/packages/agentproxy/ca_test.go index 759c8888..6bcbd390 100644 --- a/packages/agentproxy/ca_test.go +++ b/packages/agentproxy/ca_test.go @@ -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) { @@ -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") @@ -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") } } diff --git a/packages/agentproxy/cache.go b/packages/agentproxy/cache.go index 39146cb5..abc254b7 100644 --- a/packages/agentproxy/cache.go +++ b/packages/agentproxy/cache.go @@ -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, @@ -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{ @@ -227,13 +256,10 @@ 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, @@ -241,7 +267,7 @@ func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService, headerPurpose: cred.HeaderPurpose, placeholder: cred.PlaceholderValue, surfaces: cred.SubstitutionSurfaces, - dynamic: &dynamicCredentialRef{key: key, field: cred.DynamicSecretField}, + dynamic: ref, }) continue } @@ -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 { @@ -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 @@ -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 diff --git a/packages/agentproxy/connect_live_test.go b/packages/agentproxy/connect_live_test.go index 45510959..e580c4d3 100644 --- a/packages/agentproxy/connect_live_test.go +++ b/packages/agentproxy/connect_live_test.go @@ -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{}, diff --git a/packages/agentproxy/connect_test.go b/packages/agentproxy/connect_test.go index 550f6200..3471cb31 100644 --- a/packages/agentproxy/connect_test.go +++ b/packages/agentproxy/connect_test.go @@ -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() @@ -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 } @@ -98,7 +98,7 @@ func TestConnectTunnelInjectsCredentialsAndKeepsAlive(t *testing.T) { ps := &proxyServer{ opts: Options{UnmatchedHost: UnmatchedAllow}, ca: ca, - cache: cache, + resolver: cache, transport: stub, } @@ -175,7 +175,7 @@ func TestConnectRequiresProxyAuth(t *testing.T) { ps := &proxyServer{ opts: Options{UnmatchedHost: UnmatchedAllow}, ca: ca, - cache: cache, + resolver: cache, transport: &stubRoundTripper{}, } diff --git a/packages/agentproxy/forward_activity_test.go b/packages/agentproxy/forward_activity_test.go index 05b587b9..f271cadb 100644 --- a/packages/agentproxy/forward_activity_test.go +++ b/packages/agentproxy/forward_activity_test.go @@ -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{}, } @@ -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)}, } diff --git a/packages/agentproxy/forward_test.go b/packages/agentproxy/forward_test.go index ccfa5f2f..47ae78ba 100644 --- a/packages/agentproxy/forward_test.go +++ b/packages/agentproxy/forward_test.go @@ -30,7 +30,7 @@ func newTestProxy(t *testing.T, unmatchedHost, jwt string, scope agentScope, ser } ps := &proxyServer{ opts: Options{UnmatchedHost: unmatchedHost}, - cache: cache, + resolver: cache, transport: &http.Transport{}, } client, server := net.Pipe() diff --git a/packages/agentproxy/local.go b/packages/agentproxy/local.go new file mode 100644 index 00000000..be53dd32 --- /dev/null +++ b/packages/agentproxy/local.go @@ -0,0 +1,162 @@ +package agentproxy + +import ( + "sync" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/rs/zerolog/log" +) + +// LocalOptions switches the proxy into local mode (`agent-proxy run`): one developer, one agent, one +// scope. Requests carry no Proxy-Authorization; everything uses the scope and token fixed here. +type LocalOptions struct { + ProjectID string + Environment string + SecretPath string + + // UserToken returns the developer's current access token; called per request so it can rotate. + UserToken func() string + + // CADir persists the local root CA so it survives across runs and can be trusted in the OS trust + // store. Must be a sandbox-denied path, or the agent could read the key. Empty means in-memory. + CADir string + + // IdentityID/IdentityName label activity records (no wire JWT to decode them from). + IdentityID string + IdentityName string +} + +func (l *LocalOptions) scope() agentScope { + return agentScope{projectID: l.ProjectID, environment: l.Environment, secretPath: l.SecretPath} +} + +// localResolver is the single-snapshot counterpart of agentCache: no keyed map, because the one caller +// is fixed at startup. The snapshot refreshes on the poll loop and is dropped if authorization lapses. +type localResolver struct { + opts *LocalOptions + leases *leaseStore + + mu sync.Mutex + services []*resolvedService + valid bool + + // noLeaseWarned keeps the unleasable warning to once per secret, not once per poll. + noLeaseWarned map[string]bool +} + +func newLocalResolver(opts *LocalOptions, leases *leaseStore) *localResolver { + return &localResolver{opts: opts, leases: leases} +} + +func (l *localResolver) get(_ string, _ agentScope) ([]*resolvedService, error) { + l.mu.Lock() + if l.valid { + snapshot := l.services + l.mu.Unlock() + return snapshot, nil + } + l.mu.Unlock() + + resolved, err := l.resolveSnapshot() + if err != nil { + return nil, err + } + l.mu.Lock() + l.services = resolved + l.valid = true + l.mu.Unlock() + return resolved, nil +} + +func (l *localResolver) resolveSnapshot() ([]*resolvedService, error) { + // One identity for discovery and value-fetch, and no CanProxy filter: locally the gate is Read + // Value alone. + token := l.opts.UserToken() + return resolveServices(l.opts.scope(), resolveParams{ + discoveryToken: token, + valueToken: func() string { return token }, + includeNonProxyable: true, + registerDynamic: func(cred api.ProxiedServiceCredential, projectSlug string) *dynamicCredentialRef { + // The developer is the minter here, so their own Lease permission is the gate; skipping with one + // warning beats a lease that can never be minted failing every request. Reads the opposite way + // from `connect`, where an agent that can lease could mint for itself and bypass the proxy. + if !cred.CallerCanLease { + l.warnUnleasable(cred.DynamicSecretName) + return nil + } + + // leaseKey.jwt stays empty: local mode has no wire credential, so the scope alone identifies + // the caller. activeJWTs derives its liveness key the same way. + key := leaseKey{ + scope: l.opts.scope(), + secretName: cred.DynamicSecretName, + configHash: canonicalConfigHash(cred.DynamicSecretConfig), + } + l.leases.register(key, leaseSpec{projectSlug: projectSlug, config: cred.DynamicSecretConfig}) + return &dynamicCredentialRef{key: key, field: cred.DynamicSecretField} + }, + }) +} + +// warnUnleasable reports a dynamic secret the developer has no Lease permission for, once per secret. +func (l *localResolver) warnUnleasable(name string) { + l.mu.Lock() + if l.noLeaseWarned == nil { + l.noLeaseWarned = map[string]bool{} + } + already := l.noLeaseWarned[name] + l.noLeaseWarned[name] = true + l.mu.Unlock() + if already { + return + } + log.Warn().Msgf("skipping dynamic secret %q: your account cannot create leases for it, so that credential cannot be brokered. Ask for the Lease permission on this dynamic secret.", name) +} + +func (l *localResolver) identity(_ string, _ agentScope) (string, string, bool) { + return l.opts.IdentityID, l.opts.IdentityName, true +} + +func (l *localResolver) refreshActive() { + l.mu.Lock() + haveSnapshot := l.valid + l.mu.Unlock() + if !haveSnapshot { + return + } + + resolved, err := l.resolveSnapshot() + if err != nil { + if isAuthError(err) { + log.Warn().Err(err).Msg("developer authorization no longer valid; dropping brokered credentials") + l.close() + return + } + log.Warn().Err(err).Msg("failed to refresh brokered credentials; keeping the previous snapshot") + return + } + l.mu.Lock() + l.services = resolved + l.valid = true + l.mu.Unlock() +} + +// activeJWTs tells the lease loop which leases are live. One caller means one key, derived the same +// way as the leaseKey in resolveSnapshot. Empty while the snapshot is invalid, so a dropped +// authorization stops lease renewal too. +func (l *localResolver) activeJWTs() map[string]struct{} { + l.mu.Lock() + defer l.mu.Unlock() + if !l.valid { + return map[string]struct{}{} + } + return map[string]struct{}{cacheKey("", l.opts.scope()): {}} +} + +// close drops the snapshot (fail closed); the next request must re-resolve. +func (l *localResolver) close() { + l.mu.Lock() + defer l.mu.Unlock() + l.services = nil + l.valid = false +} diff --git a/packages/agentproxy/local_ca_store.go b/packages/agentproxy/local_ca_store.go new file mode 100644 index 00000000..69eef8ed --- /dev/null +++ b/packages/agentproxy/local_ca_store.go @@ -0,0 +1,136 @@ +package agentproxy + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/gofrs/flock" +) + +const ( + localCACertFile = "local-ca.crt" + localCAKeyFile = "local-ca.key" + // localCARenewMargin regenerates the persistent root before it actually expires. + localCARenewMargin = 24 * time.Hour +) + +// LocalCACertPath is the persistent root's public-cert path within a CA dir (what the OS trust store +// and the child's CA bundle reference). +func LocalCACertPath(dir string) string { return filepath.Join(dir, localCACertFile) } + +// newPersistentLocalCaManager loads the local root from dir, regenerating it if missing, unparseable, +// or near expiry. Keeping the key on disk means the same root survives across runs and can be trusted +// once in the OS trust store, so dir must be a path the sandbox denies. A file lock keeps two +// concurrent first-runs from both writing. +func newPersistentLocalCaManager(dir string) (*caManager, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("failed to create local CA dir: %w", err) + } + unlock, err := lockDir(dir) + if err != nil { + return nil, err + } + defer unlock() + + certPath := filepath.Join(dir, localCACertFile) + keyPath := filepath.Join(dir, localCAKeyFile) + + if key, cert, ok := loadLocalRoot(certPath, keyPath); ok { + return caManagerFromRoot(key, cert), nil + } + + key, cert, err := generateLocalRoot() + if err != nil { + return nil, err + } + if err := writeLocalRoot(certPath, keyPath, key, cert); err != nil { + return nil, err + } + return caManagerFromRoot(key, cert), nil +} + +// loadLocalRoot reads and validates the root; ok is false if anything is missing, unparseable, not a +// CA, near expiry, or a key/cert pair that doesn't belong together (caller regenerates). +func loadLocalRoot(certPath, keyPath string) (*ecdsa.PrivateKey, *x509.Certificate, bool) { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return nil, nil, false + } + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return nil, nil, false + } + cb, _ := pem.Decode(certPEM) + kb, _ := pem.Decode(keyPEM) + if cb == nil || kb == nil { + return nil, nil, false + } + cert, err := x509.ParseCertificate(cb.Bytes) + if err != nil || !cert.IsCA { + return nil, nil, false + } + if time.Now().Add(localCARenewMargin).After(cert.NotAfter) { + return nil, nil, false + } + key, err := x509.ParseECPrivateKey(kb.Bytes) + if err != nil { + return nil, nil, false + } + // Cert and key are two separate writes, so a crash between them leaves a new cert beside an old key. + // Both parse and neither is expired, so without this check the store never self-heals. + certPub, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok || !certPub.Equal(key.Public()) { + return nil, nil, false + } + return key, cert, true +} + +// writeLocalRoot writes the cert (public) and key (0600) atomically via temp-then-rename. +func writeLocalRoot(certPath, keyPath string, key *ecdsa.PrivateKey, cert *x509.Certificate) error { + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) + der, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) + if err := writeFileAtomic(certPath, certPEM, 0o600); err != nil { + return err + } + return writeFileAtomic(keyPath, keyPEM, 0o600) +} + +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// lockDir takes an exclusive lock on a lockfile in dir; the returned func releases it. Uses gofrs/flock +// so the file is portable (the CLI also builds for Windows) rather than a raw unix.Flock. +func lockDir(dir string) (func(), error) { + fl := flock.New(filepath.Join(dir, ".ca.lock")) + if err := fl.Lock(); err != nil { + return nil, fmt.Errorf("failed to lock CA dir: %w", err) + } + return func() { _ = fl.Unlock() }, nil +} diff --git a/packages/agentproxy/local_ca_store_test.go b/packages/agentproxy/local_ca_store_test.go new file mode 100644 index 00000000..ef9d9e05 --- /dev/null +++ b/packages/agentproxy/local_ca_store_test.go @@ -0,0 +1,117 @@ +package agentproxy + +import ( + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "testing" +) + +func TestPersistentLocalCAReuseAndHeal(t *testing.T) { + dir := t.TempDir() + + ca1, err := newPersistentLocalCaManager(dir) + if err != nil { + t.Fatal(err) + } + pem1 := ca1.RootPEM() + if len(pem1) == 0 { + t.Fatal("expected a root cert") + } + + // Files exist with the right perms; key is 0600. + keyInfo, err := os.Stat(filepath.Join(dir, localCAKeyFile)) + if err != nil { + t.Fatal(err) + } + if keyInfo.Mode().Perm() != 0o600 { + t.Fatalf("key perms = %v, want 0600", keyInfo.Mode().Perm()) + } + + // Second call reuses the same root (stable across runs). + ca2, err := newPersistentLocalCaManager(dir) + if err != nil { + t.Fatal(err) + } + if string(ca2.RootPEM()) != string(pem1) { + t.Fatal("expected the persistent root to be reused, got a different cert") + } + + // Corrupt the cert -> next call self-heals with a fresh root. + if err := os.WriteFile(filepath.Join(dir, localCACertFile), []byte("garbage"), 0o600); err != nil { + t.Fatal(err) + } + ca3, err := newPersistentLocalCaManager(dir) + if err != nil { + t.Fatal(err) + } + if string(ca3.RootPEM()) == string(pem1) { + t.Fatal("expected a regenerated root after corruption") + } + if len(ca3.RootPEM()) == 0 { + t.Fatal("expected a valid regenerated root") + } +} + +// A crash between the cert write and the key write leaves a new cert beside the old key. Both parse +// and neither is expired, so only a pair check catches it. +func TestPersistentLocalCAHealsMismatchedPair(t *testing.T) { + dirA, dirB := t.TempDir(), t.TempDir() + caA, err := newPersistentLocalCaManager(dirA) + if err != nil { + t.Fatal(err) + } + if _, err := newPersistentLocalCaManager(dirB); err != nil { + t.Fatal(err) + } + + // Graft dirB's key next to dirA's cert: a valid-but-unrelated pair. + otherKey, err := os.ReadFile(filepath.Join(dirB, localCAKeyFile)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dirA, localCAKeyFile), otherKey, 0o600); err != nil { + t.Fatal(err) + } + + healed, err := newPersistentLocalCaManager(dirA) + if err != nil { + t.Fatal(err) + } + if string(healed.RootPEM()) == string(caA.RootPEM()) { + t.Fatal("expected a regenerated root after a mismatched key/cert pair, got the stale cert") + } + + // And the regenerated pair must actually work. + leaf, err := healed.mintLeaf("api.stripe.com") + if err != nil { + t.Fatalf("minting from the healed root failed: %v", err) + } + block, _ := pem.Decode(healed.RootPEM()) + if block == nil { + t.Fatal("healed root PEM did not decode") + } + root, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatal(err) + } + parsed, err := x509.ParseCertificate(leaf.Certificate[0]) + if err != nil { + t.Fatal(err) + } + if err := parsed.CheckSignatureFrom(root); err != nil { + t.Fatalf("leaf does not chain to the healed root: %v", err) + } +} + +func TestPersistentLocalCAMintsFromStoredRoot(t *testing.T) { + dir := t.TempDir() + ca, err := newPersistentLocalCaManager(dir) + if err != nil { + t.Fatal(err) + } + if _, err := ca.mintLeaf("api.stripe.com"); err != nil { + t.Fatalf("minting a leaf from the persistent root failed: %v", err) + } +} diff --git a/packages/agentproxy/local_test.go b/packages/agentproxy/local_test.go new file mode 100644 index 00000000..44cc4289 --- /dev/null +++ b/packages/agentproxy/local_test.go @@ -0,0 +1,474 @@ +package agentproxy + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" +) + +// serveTestProxy serves ps over an in-memory pipe (one connection) and returns the client end, +// mirroring newTestProxy's harness for the remote tests. +func serveTestProxy(t *testing.T, ps *proxyServer) net.Conn { + t.Helper() + client, server := net.Pipe() + l := newOneShotListener(server) + srv := ps.newFrontServer() + srv.ConnState = func(_ net.Conn, s http.ConnState) { + if s == http.StateClosed || s == http.StateHijacked { + _ = l.Close() + } + } + go func() { _ = srv.Serve(l) }() + t.Cleanup(func() { _ = client.Close() }) + return client +} + +// newLocalTestProxy builds a local-mode proxyServer with an injected snapshot and serves it. +func newLocalTestProxy(t *testing.T, local *LocalOptions, services []*resolvedService, rt http.RoundTripper) net.Conn { + t.Helper() + resolver := newLocalResolver(local, newLeaseStore(func() string { return "" })) + resolver.services = services + resolver.valid = true + return serveTestProxy(t, &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedAllow, Local: local}, + resolver: resolver, + transport: rt, + }) +} + +func TestLocalCaMintsVerifiableLeaves(t *testing.T) { + ca, err := newLocalCaManager() + if err != nil { + t.Fatal(err) + } + + rootPEM := ca.RootPEM() + if len(rootPEM) == 0 { + t.Fatal("expected a root PEM from the local CA") + } + block, _ := pem.Decode(rootPEM) + if block == nil { + t.Fatal("root PEM did not decode") + } + root, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatal(err) + } + if !root.IsCA { + t.Fatal("local root is not a CA certificate") + } + pub, ok := root.PublicKey.(*ecdsa.PublicKey) + if !ok || pub.Curve != elliptic.P256() { + t.Fatalf("local root must be ECDSA P-256, got %T", root.PublicKey) + } + if err := root.CheckSignatureFrom(root); err != nil { + t.Fatalf("local root is not self-signed: %v", err) + } + + leaf, err := ca.mintLeaf("api.stripe.com") + if err != nil { + t.Fatal(err) + } + parsedLeaf, err := x509.ParseCertificate(leaf.Certificate[0]) + if err != nil { + t.Fatal(err) + } + pool := x509.NewCertPool() + pool.AddCert(root) + if _, err := parsedLeaf.Verify(x509.VerifyOptions{ + Roots: pool, + DNSName: "api.stripe.com", + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }); err != nil { + t.Fatalf("leaf does not verify against the local root: %v", err) + } + + if newCaManager(func() string { return "" }).RootPEM() != nil { + t.Fatal("remote caManager must not expose a root PEM") + } +} + +func TestLocalResolverSnapshotLifecycle(t *testing.T) { + local := &LocalOptions{ + ProjectID: "proj", Environment: "prod", SecretPath: "/", + UserToken: func() string { t.Fatal("token must not be used without a snapshot refresh"); return "" }, + IdentityID: "user-1", IdentityName: "dev", + } + r := newLocalResolver(local, newLeaseStore(func() string { return "" })) + + // Without a snapshot, refreshActive must be a no-op (it must not call the API). + r.refreshActive() + + r.services = []*resolvedService{{name: "svc"}} + r.valid = true + + got, err := r.get("ignored-wire-credential", agentScope{projectID: "other"}) + if err != nil || len(got) != 1 || got[0].name != "svc" { + t.Fatalf("get should return the snapshot regardless of wire args, got %v, %v", got, err) + } + + id, name, ok := r.identity("", agentScope{}) + if !ok || id != "user-1" || name != "dev" { + t.Fatalf("identity should be the configured user, got %q %q %v", id, name, ok) + } + + // With a live snapshot the lease loop must see exactly one key, and it must be the key derived from + // the empty wire JWT plus the fixed scope, or registered leases would never be renewed. + live := r.activeJWTs() + if len(live) != 1 { + t.Fatalf("expected exactly one live lease key, got %d", len(live)) + } + if _, ok := live[cacheKey("", local.scope())]; !ok { + t.Fatalf("live key must match the registered leaseKey scope, got %v", live) + } + + r.close() + if r.valid || r.services != nil { + t.Fatal("close must drop the snapshot (fail closed)") + } + // Fail closed applies to leases too: no snapshot means no renewal. + if len(r.activeJWTs()) != 0 { + t.Fatal("a dropped snapshot must stop lease renewal") + } +} + +// resolveSnapshot must register a lease for a leasable dynamic credential, and skip one the developer +// cannot lease rather than registering a lease that could never be minted. +func TestLocalResolverRegistersLeasableDynamicCredentials(t *testing.T) { + origURL := config.INFISICAL_URL + stubAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/v1/proxied-services") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(api.ListProxiedServicesResponse{ + ProjectSlug: "my-project", + Services: []api.ProxiedService{{ + Name: "db-api", + HostPattern: "db.internal", + IsEnabled: true, + Credentials: []api.ProxiedServiceCredential{ + { + Role: "header-rewrite", + HeaderName: "Authorization", + DynamicSecretName: "leasable-pg", + DynamicSecretField: "DB_PASSWORD", + CallerCanLease: true, + }, + { + Role: "header-rewrite", + HeaderName: "X-Other", + DynamicSecretName: "forbidden-pg", + DynamicSecretField: "DB_PASSWORD", + CallerCanLease: false, + }, + }, + }}, + }) + })) + defer stubAPI.Close() + config.INFISICAL_URL = stubAPI.URL + defer func() { config.INFISICAL_URL = origURL }() + + local := &LocalOptions{ + ProjectID: "proj", Environment: "prod", SecretPath: "/", + UserToken: func() string { return "dev-token" }, + } + leases := &leaseStore{entries: make(map[leaseKey]*leaseEntry), wake: make(chan struct{}, 1)} + resolver := newLocalResolver(local, leases) + + services, err := resolver.resolveSnapshot() + if err != nil { + t.Fatal(err) + } + if len(services) != 1 { + t.Fatalf("expected one service, got %d", len(services)) + } + + // Only the leasable credential survives. + if n := len(services[0].credentials); n != 1 { + t.Fatalf("expected only the leasable dynamic credential, got %d", n) + } + cred := services[0].credentials[0] + if cred.dynamic == nil { + t.Fatal("the surviving credential should carry a dynamic lease ref") + } + if cred.dynamic.key.secretName != "leasable-pg" { + t.Errorf("wrong secret registered: %q", cred.dynamic.key.secretName) + } + if cred.dynamic.key.scope != local.scope() { + t.Errorf("lease key scope must be the run's scope, got %+v", cred.dynamic.key.scope) + } + + // The lease store must hold exactly the leasable one, under a key the refresh loop considers live. + leases.mu.Lock() + n := len(leases.entries) + _, registered := leases.entries[cred.dynamic.key] + leases.mu.Unlock() + if n != 1 || !registered { + t.Fatalf("expected exactly the leasable secret registered, got %d entries", n) + } +} + +// Local mode brokers dynamic secrets the same way remote mode does: the credential resolves through a +// lease minted with the developer's own token, and the minted value reaches the wire. +func TestLocalModeBrokersDynamicSecret(t *testing.T) { + local := &LocalOptions{ + ProjectID: "proj", Environment: "prod", SecretPath: "/", + UserToken: func() string { return "dev-token" }, + } + + var mintedWith leaseMintArgs + var mintCalls int + leases := &leaseStore{entries: make(map[leaseKey]*leaseEntry), wake: make(chan struct{}, 1)} + leases.mint = func(args leaseMintArgs) (leaseMintResult, error) { + mintCalls++ + mintedWith = args + return leaseMintResult{ + leaseID: "lease-1", + expireAt: time.Now().Add(time.Hour), + data: map[string]interface{}{"DB_PASSWORD": "minted_secret"}, + }, nil + } + + resolver := newLocalResolver(local, leases) + + // Register a dynamic credential exactly as resolveSnapshot would, then hand the resolver a snapshot + // that references it. + key := leaseKey{ + scope: local.scope(), + secretName: "my-postgres", + configHash: canonicalConfigHash(nil), + } + leases.register(key, leaseSpec{projectSlug: "my-project", config: nil}) + resolver.services = []*resolvedService{{ + name: "internal-db-api", + hostPatterns: parseHostPatterns("example.com"), + isEnabled: true, + credentials: []resolvedCredential{{ + role: roleHeaderRewrite, + headerName: "Authorization", + headerPrefix: "Bearer", + dynamic: &dynamicCredentialRef{key: key, field: "DB_PASSWORD"}, + }}, + }} + resolver.valid = true + + stub := &stubRoundTripper{respBody: "ok"} + client := serveTestProxy(t, &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedAllow, Local: local}, + resolver: resolver, + leases: leases, + transport: stub, + }) + _ = client.SetDeadline(time.Now().Add(10 * time.Second)) + + if _, err := fmt.Fprintf(client, "GET http://example.com/v1 HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"); err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + if mintCalls != 1 { + t.Fatalf("expected the lease to be minted lazily exactly once, got %d calls", mintCalls) + } + if mintedWith.secretName != "my-postgres" || mintedWith.projectSlug != "my-project" { + t.Errorf("lease minted with wrong args: %+v", mintedWith) + } + if mintedWith.environment != "prod" || mintedWith.path != "/" { + t.Errorf("lease must be minted in the run's scope, got env=%q path=%q", mintedWith.environment, mintedWith.path) + } + + stub.mu.Lock() + defer stub.mu.Unlock() + if len(stub.gotAuth) != 1 || stub.gotAuth[0] != "Bearer minted_secret" { + t.Fatalf("expected the minted lease value on the wire, got %v", stub.gotAuth) + } +} + +func TestLocalModeServesWithoutProxyAuth(t *testing.T) { + local := &LocalOptions{ + ProjectID: "proj", Environment: "prod", SecretPath: "/", + UserToken: func() string { return "dev-token" }, + } + services := []*resolvedService{{ + name: "stripe", + hostPatterns: parseHostPatterns("example.com"), + isEnabled: true, + credentials: []resolvedCredential{ + {role: roleHeaderRewrite, headerName: "Authorization", headerPrefix: "Bearer", value: "real_secret"}, + }, + }} + stub := &stubRoundTripper{respBody: "ok"} + client := newLocalTestProxy(t, local, services, stub) + _ = client.SetDeadline(time.Now().Add(10 * time.Second)) + + // No Proxy-Authorization header anywhere: local mode must serve and inject regardless. + if _, err := fmt.Fprintf(client, "GET http://example.com/v1 HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"); err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + stub.mu.Lock() + defer stub.mu.Unlock() + if len(stub.gotAuth) != 1 || stub.gotAuth[0] != "Bearer real_secret" { + t.Fatalf("expected injected credential, got %v", stub.gotAuth) + } +} + +func TestRemoteModeChallengesWithoutProxyAuth(t *testing.T) { + cache := newAgentCache(func() string { return "" }, newLeaseStore(func() string { return "" })) + client := serveTestProxy(t, &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedAllow}, + resolver: cache, + transport: &http.Transport{}, + }) + _ = client.SetDeadline(time.Now().Add(10 * time.Second)) + + if _, err := fmt.Fprintf(client, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"); err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusProxyAuthRequired { + t.Fatalf("expected 407 in remote mode, got %d", resp.StatusCode) + } + if resp.Header.Get("Proxy-Authenticate") != "Basic" { + t.Fatal("expected a Proxy-Authenticate challenge") + } +} + +func TestUnmatchedBlockRespectsAllowHost(t *testing.T) { + local := &LocalOptions{ + ProjectID: "proj", Environment: "prod", SecretPath: "/", + UserToken: func() string { return "dev-token" }, + } + resolver := newLocalResolver(local, newLeaseStore(func() string { return "" })) + resolver.services = nil // no proxied services: every host is "unmatched" + resolver.valid = true + stub := &stubRoundTripper{respBody: "ok"} + client := serveTestProxy(t, &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedBlock, Local: local, AllowedHosts: []string{"docs.internal"}}, + resolver: resolver, + transport: stub, + }) + _ = client.SetDeadline(time.Now().Add(10 * time.Second)) + + // Allowlisted host passes through (case-insensitive) even under block. + if _, err := fmt.Fprintf(client, "GET http://Docs.Internal/x HTTP/1.1\r\nHost: docs.internal\r\nConnection: close\r\n\r\n"); err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("allowlisted host should pass through under block, got %d", resp.StatusCode) + } +} + +func TestUnmatchedBlockRejectsNonAllowlistedHost(t *testing.T) { + local := &LocalOptions{ + ProjectID: "proj", Environment: "prod", SecretPath: "/", + UserToken: func() string { return "dev-token" }, + } + resolver := newLocalResolver(local, newLeaseStore(func() string { return "" })) + resolver.valid = true + client := serveTestProxy(t, &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedBlock, Local: local, AllowedHosts: []string{"docs.internal"}}, + resolver: resolver, + transport: &stubRoundTripper{respBody: "ok"}, + }) + _ = client.SetDeadline(time.Now().Add(10 * time.Second)) + + if _, err := fmt.Fprintf(client, "GET http://evil.example/x HTTP/1.1\r\nHost: evil.example\r\nConnection: close\r\n\r\n"); err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("non-allowlisted host should be blocked, got %d", resp.StatusCode) + } +} + +func TestProxyLifecycleServeShutdown(t *testing.T) { + p, err := New(Options{ + UnmatchedHost: UnmatchedAllow, + Local: &LocalOptions{ + ProjectID: "proj", Environment: "prod", SecretPath: "/", + UserToken: func() string { return "" }, + }, + }) + if err != nil { + t.Fatal(err) + } + if len(p.LocalRootPEM()) == 0 { + t.Fatal("expected a local root PEM in local mode") + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- p.Serve(ln) }() + + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 5*time.Second) + if err != nil { + t.Fatalf("proxy is not accepting on %s: %v", ln.Addr(), err) + } + _ = conn.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := p.Shutdown(ctx); err != nil { + t.Fatalf("Shutdown: %v", err) + } + select { + case err := <-done: + if err != nil { + t.Fatalf("Serve returned an error after Shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Serve did not return after Shutdown") + } + + // Shutdown is idempotent. + if err := p.Shutdown(ctx); err != nil { + t.Fatal(err) + } +} diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index c6048b81..1b6016ce 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -82,12 +82,27 @@ type Options struct { UnmatchedHost string PollInterval time.Duration ProxyToken func() string + + // Local switches the proxy into local coupled mode; nil = remote. + Local *LocalOptions + + // AllowedHosts pass through with no credential even under UnmatchedBlock (run --allow-host). + AllowedHosts []string +} + +// serviceResolver resolves credentials: agentCache (remote) or localResolver (local). +type serviceResolver interface { + get(jwt string, scope agentScope) ([]*resolvedService, error) + identity(jwt string, scope agentScope) (id, name string, ok bool) + refreshActive() + activeJWTs() map[string]struct{} + close() } type proxyServer struct { opts Options ca *caManager - cache *agentCache + resolver serviceResolver leases *leaseStore transport http.RoundTripper @@ -95,18 +110,44 @@ type proxyServer struct { usageMu sync.Mutex usage map[string]struct{} usageFlushing atomic.Bool + // usageWarnOnce keeps a rejected usage report to one warning per proxy, not one per poll. + usageWarnOnce sync.Once } -func newProxyServer(opts Options) *proxyServer { +func newProxyServer(opts Options) (*proxyServer, error) { + if opts.Local != nil && opts.ProxyToken == nil { + opts.ProxyToken = opts.Local.UserToken + } leases := newLeaseStore(opts.ProxyToken) + + var ca *caManager + var resolver serviceResolver + if opts.Local != nil { + var localCa *caManager + var err error + if opts.Local.CADir != "" { + localCa, err = newPersistentLocalCaManager(opts.Local.CADir) + } else { + localCa, err = newLocalCaManager() + } + if err != nil { + return nil, err + } + ca = localCa + resolver = newLocalResolver(opts.Local, leases) + } else { + ca = newCaManager(opts.ProxyToken) + resolver = newAgentCache(opts.ProxyToken, leases) + } + return &proxyServer{ opts: opts, - ca: newCaManager(opts.ProxyToken), - cache: newAgentCache(opts.ProxyToken, leases), + ca: ca, + resolver: resolver, leases: leases, transport: newUpstreamTransport(), usage: make(map[string]struct{}), - } + }, nil } func (ps *proxyServer) recordUsage(serviceID string) { @@ -136,6 +177,10 @@ func (ps *proxyServer) flushUsage() { client := resty.New().SetAuthToken(ps.opts.ProxyToken()).SetTimeout(usageReportTimeout) for serviceID := range snapshot { if err := api.CallReportProxiedServiceUsage(client, serviceID); err != nil { + // Warn once: the usual cause is a missing Report Usage permission, which fails every attempt. + ps.usageWarnOnce.Do(func() { + log.Warn().Err(err).Msg("cannot report proxied-service usage, so the service's last-used time will not update; this needs the Report Usage permission on proxied services") + }) log.Debug().Err(err).Msg("failed to report proxied service usage; dropping batch") return } @@ -166,17 +211,71 @@ func (ps *proxyServer) newFrontServer() *http.Server { } } -func Start(opts Options) error { - ps := newProxyServer(opts) +// Proxy lets the caller own binding, serving, and shutdown (used by `agent-proxy run`); the remote +// `start` command uses the blocking Start wrapper below. +type Proxy struct { + ps *proxyServer + srv *http.Server + loopStop chan struct{} + stopOnce sync.Once +} - if err := ps.ca.ensureIntermediate(); err != nil { - return fmt.Errorf("failed to initialize agent proxy CA: %w", err) +func New(opts Options) (*Proxy, error) { + ps, err := newProxyServer(opts) + if err != nil { + return nil, fmt.Errorf("failed to initialize agent proxy CA: %w", err) + } + if err := ps.ca.ensureSigningCert(); err != nil { + return nil, fmt.Errorf("failed to initialize agent proxy CA: %w", err) } + return &Proxy{ + ps: ps, + srv: ps.newFrontServer(), + loopStop: make(chan struct{}), + }, nil +} - go ps.pollLoop() +// Serve runs the poll/lease loops and serves on ln until Shutdown (nil) or a serve error. +func (p *Proxy) Serve(ln net.Listener) error { + go p.ps.pollLoop(p.loopStop) + go p.ps.leases.refreshLoop(p.loopStop, p.ps.opts.PollInterval, p.ps.resolver.activeJWTs) - leaseStop := make(chan struct{}) - go ps.leases.refreshLoop(leaseStop, opts.PollInterval, ps.cache.activeJWTs) + err := p.srv.Serve(newLimitListener(ln, maxConcurrentConns)) + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +// Shutdown stops the loops, revokes leases, drains in-flight requests (bounded by ctx), and drops +// cached credentials. Idempotent. +func (p *Proxy) Shutdown(ctx context.Context) error { + var err error + p.stopOnce.Do(func() { + close(p.loopStop) + revokeCtx, cancel := context.WithTimeout(context.Background(), leaseRevokeShutdownTimeout) + defer cancel() + p.ps.leases.revokeAll(revokeCtx) + err = p.srv.Shutdown(ctx) + // Flush usage recorded since the last poll tick. `run` is frequently shorter than one poll + // interval, so without a shutdown flush a short session would never report its brokered-service + // usage; the daemon path already flushes each tick. + p.ps.flushUsage() + p.ps.resolver.close() + }) + return err +} + +// LocalRootPEM returns the local root CA's public cert (local mode only; nil otherwise). +func (p *Proxy) LocalRootPEM() []byte { + return p.ps.ca.RootPEM() +} + +func Start(opts Options) error { + p, err := New(opts) + if err != nil { + return err + } if addr := portInUse(opts.Port); addr != "" { return fmt.Errorf("port %d is already in use (%s); another process is listening. Choose a free port with --port", opts.Port, addr) @@ -189,25 +288,17 @@ func Start(opts Options) error { log.Info().Msgf("Infisical agent proxy listening on :%d", opts.Port) log.Info().Msg("per-request activity logging on: brokered=info, blocked=warn, error=error, passthrough=debug (use --log-level to filter)") - srv := ps.newFrontServer() - sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) go func() { <-sigCh log.Info().Msg("shutting down agent proxy; revoking active leases") - close(leaseStop) ctx, cancel := context.WithTimeout(context.Background(), leaseRevokeShutdownTimeout) defer cancel() - ps.leases.revokeAll(ctx) - _ = srv.Shutdown(ctx) + _ = p.Shutdown(ctx) }() - err = srv.Serve(newLimitListener(listener, maxConcurrentConns)) - if errors.Is(err, http.ErrServerClosed) { - return nil - } - return err + return p.Serve(listener) } func portInUse(port int) string { @@ -221,17 +312,31 @@ func portInUse(port int) string { return "" } -func (ps *proxyServer) pollLoop() { +func (ps *proxyServer) pollLoop(stop <-chan struct{}) { interval := ps.opts.PollInterval if interval <= 0 { interval = 60 * time.Second } ticker := time.NewTicker(interval) defer ticker.Stop() - for range ticker.C { - ps.cache.refreshActive() - go ps.flushUsage() + for { + select { + case <-ticker.C: + ps.resolver.refreshActive() + go ps.flushUsage() + case <-stop: + return + } + } +} + +// requestScope returns the request's scope and wire credential. Remote parses Proxy-Authorization; +// local serves the startup scope with no wire credential. +func (ps *proxyServer) requestScope(r *http.Request) (agentScope, string, bool) { + if l := ps.opts.Local; l != nil { + return l.scope(), "", true } + return parseProxyAuth(r.Header.Get("Proxy-Authorization")) } func (ps *proxyServer) dispatch(w http.ResponseWriter, r *http.Request) { @@ -244,7 +349,7 @@ func (ps *proxyServer) dispatch(w http.ResponseWriter, r *http.Request) { func (ps *proxyServer) handleConnect(w http.ResponseWriter, r *http.Request) { // All authentication and HTTP error responses happen before Hijack: once hijacked, no HTTP status can be sent. - scope, jwt, ok := parseProxyAuth(r.Header.Get("Proxy-Authorization")) + scope, jwt, ok := ps.requestScope(r) if !ok { writeProxyAuthChallenge(w) return @@ -257,7 +362,7 @@ func (ps *proxyServer) handleConnect(w http.ResponseWriter, r *http.Request) { } // Authenticate before minting: otherwise any syntactically valid Proxy-Authorization header forces unbounded key generation and leaf-cache growth. - if _, err := ps.cache.get(jwt, scope); err != nil { + if _, err := ps.resolver.get(jwt, scope); err != nil { if isAuthError(err) { http.Error(w, "proxy authorization failed", http.StatusForbidden) } else { @@ -339,7 +444,7 @@ func (ps *proxyServer) handlePlainForward(w http.ResponseWriter, r *http.Request return } - scope, jwt, ok := parseProxyAuth(r.Header.Get("Proxy-Authorization")) + scope, jwt, ok := ps.requestScope(r) if !ok { writeProxyAuthChallenge(w) return @@ -488,18 +593,18 @@ type forwardOutcome struct { } func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt string, scope agentScope) (*http.Response, forwardOutcome, error) { - services, err := ps.cache.get(jwt, scope) + services, err := ps.resolver.get(jwt, scope) if err != nil { return nil, forwardOutcome{}, fmt.Errorf("failed to resolve agent permissions: %w", err) } // Capture identity now; reading it after the round trip would race a cache eviction and drop the record. - agentID, agentName, resolved := ps.cache.identity(jwt, scope) + agentID, agentName, resolved := ps.resolver.identity(jwt, scope) outcome := forwardOutcome{agentID: agentID, agentName: agentName, identityResolved: resolved} svc := bestMatch(services, hostname, port, req.URL.Path) - if svc == nil && ps.opts.UnmatchedHost == UnmatchedBlock { + if svc == nil && ps.opts.UnmatchedHost == UnmatchedBlock && !ps.hostAllowlisted(hostname) { return nil, outcome, fmt.Errorf("host %q has no matching proxied service: %w", hostname, errHostBlocked) } @@ -530,6 +635,16 @@ func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt st return resp, outcome, nil } +// hostAllowlisted reports whether hostname is in AllowedHosts (case-insensitive). +func (ps *proxyServer) hostAllowlisted(hostname string) bool { + for _, h := range ps.opts.AllowedHosts { + if strings.EqualFold(h, hostname) { + return true + } + } + return false +} + func (ps *proxyServer) materializeCredentials(svc *resolvedService) []resolvedCredential { creds := make([]resolvedCredential, 0, len(svc.credentials)) for _, cred := range svc.credentials { diff --git a/packages/agentproxy/reflect_test.go b/packages/agentproxy/reflect_test.go index 0fdf9c53..769274b7 100644 --- a/packages/agentproxy/reflect_test.go +++ b/packages/agentproxy/reflect_test.go @@ -64,7 +64,7 @@ func TestForwardPassesResponseHeadersThrough(t *testing.T) { respHeader.Set("Location", "https://example.com/next?token=real_secret") ps := &proxyServer{ opts: Options{UnmatchedHost: UnmatchedAllow}, - cache: cache, + resolver: cache, transport: reflectingTransport{header: respHeader}, } diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index 51a7fe43..7363874e 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -6,15 +6,14 @@ import ( "net/url" "os" "os/exec" - "os/signal" "path/filepath" "sort" "strings" - "syscall" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/sandbox" "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" "github.com/go-resty/resty/v2" @@ -84,8 +83,36 @@ var credentialEnvKeys = []string{ util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, } +// Addresses of host IPC endpoints. Not secret values, but handing them to the agent points it at +// sockets it can use: the SSH/GPG agents as a signing oracle, and the session bus, where +// systemd --user StartTransientUnit runs a process outside the sandbox. Unix sockets are not +// namespaced, so on Linux dropping these is what keeps them out of reach. +// Scrubbed by default; --pass-env re-admits a specific one. +var authAgentEnvKeys = []string{ + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + "GPG_AGENT_INFO", + "DBUS_SESSION_BUS_ADDRESS", + "DBUS_SYSTEM_BUS_ADDRESS", + "XDG_RUNTIME_DIR", +} + var requiredNoProxy = []string{"localhost", "127.0.0.1"} +// setProxyEnv points the child's HTTP clients at the proxy. Both letter cases are set on purpose: +// curl honours only the lowercase http_proxy for plain-HTTP URLs, so an uppercase-only environment +// sends those requests to DNS instead of the proxy. Shared by connect and run so the two can't drift. +func setProxyEnv(env map[string]string, proxyURL, noProxy string) { + env["HTTP_PROXY"] = proxyURL + env["http_proxy"] = proxyURL + env["HTTPS_PROXY"] = proxyURL + env["https_proxy"] = proxyURL + env["NO_PROXY"] = noProxy + env["no_proxy"] = noProxy + env["NODE_USE_ENV_PROXY"] = "1" + env["OPENCLAW_PROXY_URL"] = proxyURL +} + func mergeNoProxy(operatorEntries ...string) string { seen := make(map[string]bool) var merged []string @@ -363,11 +390,7 @@ func buildAgentEnv(proxy, caPath, jwt, extraNoProxy string, placeholders map[str } } - env["HTTPS_PROXY"] = proxy - env["HTTP_PROXY"] = proxy - env["NO_PROXY"] = mergeNoProxy(append(operatorNoProxy, extraNoProxy)...) - env["NODE_USE_ENV_PROXY"] = "1" - env["OPENCLAW_PROXY_URL"] = proxy + setProxyEnv(env, proxy, mergeNoProxy(append(operatorNoProxy, extraNoProxy)...)) for _, k := range caTrustEnvVars { env[k] = caPath @@ -404,28 +427,20 @@ func runAgentProcess(args, env []string) error { proc.Stderr = os.Stderr proc.Env = env - sigChannel := make(chan os.Signal, 1) - signal.Notify(sigChannel) - if err := proc.Start(); err != nil { return err } - go func() { - for sig := range sigChannel { - _ = proc.Process.Signal(sig) - } - }() - - if err := proc.Wait(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok { - os.Exit(ws.ExitStatus()) - } - } - return err + stopForwarding := sandbox.ForwardTerminationSignals(proc) + err := proc.Wait() + stopForwarding() + if err == nil { + return nil + } + if code, ok := sandbox.WaitExitCode(err); ok { + os.Exit(code) } - return nil + return err } func init() { diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go new file mode 100644 index 00000000..7b51f923 --- /dev/null +++ b/packages/cmd/agent_proxy_run.go @@ -0,0 +1,543 @@ +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/Infisical/infisical-merge/packages/agentproxy" + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/sandbox" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/fatih/color" + "github.com/go-resty/resty/v2" + "github.com/mattn/go-isatty" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var agentProxyRunCmd = &cobra.Command{ + Use: "run [flags] -- [agent start command]", + Short: "Launch an agent on this machine, sandboxed, with credentials brokered on the wire", + Example: "infisical secrets agent-proxy run --env=dev --path=/myapp -- claude", + DisableFlagsInUseLine: true, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("provide the agent command to run after '--', e.g. -- claude") + } + return nil + }, + Run: runAgentProxyRun, +} + +// secretShapedEnvSubstrings: env vars whose name contains any of these are scrubbed from the child +// (coarse on purpose; --pass-env re-admits a specific one). +var secretShapedEnvSubstrings = []string{ + "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "API_KEY", "APIKEY", "PRIVATE_KEY", "ACCESS_KEY", +} + +func runAgentProxyRun(cmd *cobra.Command, args []string) { + if cmd.Flags().Changed("proxy") { + util.HandleError(fmt.Errorf("--proxy is not valid for 'run' (it starts its own ephemeral proxy); use 'agent-proxy connect --proxy=host:port' for a remote proxy")) + } + + // Same resolution order as `connect`: flag, then env var, then .infisical.json. + environment := util.ResolveEnvironmentName(cmd) + if environment == "" { + util.HandleError(fmt.Errorf("the environment is required; pass --env, set INFISICAL_ENVIRONMENT, or set defaultEnvironment in .infisical.json")) + } + + secretPath := util.ResolveSecretPath(cmd) + + projectID, err := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "projectId", []string{util.INFISICAL_PROJECT_ID_NAME}, "") + if err != nil { + util.HandleError(err, "Unable to parse --projectId") + } + if projectID == "" { + if workspaceFile, wsErr := util.GetWorkSpaceFromFile(); wsErr == nil { + projectID = workspaceFile.WorkspaceId + } + } + if projectID == "" { + util.HandleError(fmt.Errorf("project id is required; pass --projectId, set INFISICAL_PROJECT_ID, or run inside a project with .infisical.json")) + } + + unmatchedHost, _ := cmd.Flags().GetString("unmatched-host") + if unmatchedHost != agentproxy.UnmatchedAllow && unmatchedHost != agentproxy.UnmatchedBlock { + util.HandleError(fmt.Errorf("--unmatched-host must be 'allow' or 'block', got %q", unmatchedHost)) + } + pollInterval, _ := cmd.Flags().GetInt("poll-interval") + + sandboxEnabled := resolveSandboxEnabled(cmd) + + // The single identity for the run: fetches config and secret values in the parent. The child gets none of it. + src := resolveDeveloperTokenSource(cmd) + + httpClient := resty.New().SetAuthToken(src.token()) + placeholders := fetchLocalProxiedServiceConfig(httpClient, projectID, environment, secretPath) + + local := &agentproxy.LocalOptions{ + ProjectID: projectID, + Environment: environment, + SecretPath: secretPath, + UserToken: src.token, + IdentityID: src.label, + IdentityName: src.label, + } + + // Per-run 0700 tempdir: only the public CA cert (and the unix socket on the Linux hard fence) hit disk. + tempDir, err := os.MkdirTemp("", "infisical-agent-proxy-run-") + if err != nil { + util.HandleError(err, "Unable to create a temporary directory") + } + // os.Exit (and util.HandleError) skip deferred funcs, so clean up explicitly at every exit path. + cleanup := func() { _ = os.RemoveAll(tempDir) } + fail := func(e error, messages ...string) { cleanup(); util.HandleError(e, messages...) } + + // Nothing is written unless --log-file asks for it: a file the operator did not request is one they + // cannot find, and on a tmpfs /tmp it would be memory. Without it, problems still surface on stderr + // while per-request activity is dropped, so an agent's TUI stays intact. An explicit --log-level + // overrides that filter for anyone debugging without a file. + logFile, _ := cmd.Flags().GetString("log-file") + if logFile != "" { + logF, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + fail(err, "Unable to open the log file") + } + log.Logger = log.Output(GetLoggerConfig(logF, true)) + } else { + log.Logger = log.Output(GetLoggerConfig(os.Stderr, !isatty.IsTerminal(os.Stderr.Fd()))) + if !cmd.Flags().Changed("log-level") && os.Getenv("LOG_LEVEL") == "" { + log.Logger = log.Logger.Level(zerolog.WarnLevel) + } + } + + home, _ := os.UserHomeDir() + cwd, _ := os.Getwd() + extraRead, _ := cmd.Flags().GetStringArray("allow-read") + extraWrite, _ := cmd.Flags().GetStringArray("allow-write") + allowHosts, _ := cmd.Flags().GetStringArray("allow-host") + + // macOS keeps the root under ~/.infisical (already sandbox-denied) so it can be trusted once in the + // keychain, which is what Go tools like gh need. Elsewhere the injected CA env var is enough. + if runtime.GOOS == "darwin" && home != "" { + local.CADir = filepath.Join(home, ".infisical", "agent-proxy") + } + + // The agent's own state dirs, so interactive sessions can save. Its data, not the developer's. + writePaths := absolutePaths(append(defaultAgentStateWritePaths(home), extraWrite...)) + + // --allow-read carves a read hole in the credential deny set, so name exactly what was re-opened. + readExceptions := absolutePaths(extraRead) + if sandboxEnabled && len(readExceptions) > 0 { + util.PrintWarning(fmt.Sprintf("The agent can now read %s. These paths stay read-only.", strings.Join(readExceptions, ", "))) + } + + spec := sandbox.Spec{ + Enabled: sandboxEnabled, + ReadPaths: readExceptions, + WritePaths: writePaths, + DenyPaths: sandbox.DefaultDenyPaths(home, hostRuntimeDir()), + Cwd: cwd, + TempDir: tempDir, + // Linux downgrades to SharedNet via Preflight below; macOS always fences to loopback via SBPL. + NetMode: sandbox.HardFence, + } + + // Preflight must run before choosing the listener and building the env (the proxy URL depends on + // whether the bridge is used). + backend := sandbox.NewBackend(spec) + pre, err := backend.Preflight(spec) + if err != nil { + fail(err, "Unable to check sandbox support on this host") + } + if !pre.Supported { + fail(fmt.Errorf("%s", pre.Reason)) + } + if pre.FallbackToSharedNet { + spec.NetMode = sandbox.SharedNet + util.PrintWarning(fmt.Sprintf("Unable to isolate the network on this host (%s), the agent will share your network connection. Requests are still routed through the proxy, but a program that ignores the proxy settings can reach the network directly. Credential protections are unchanged.", pre.Reason)) + } + + proxy, err := agentproxy.New(agentproxy.Options{ + UnmatchedHost: unmatchedHost, + PollInterval: time.Duration(pollInterval) * time.Second, + Local: local, + AllowedHosts: allowHosts, + }) + if err != nil { + fail(err, "Unable to start the agent proxy") + } + + // Non-fatal by design: if the keychain install is declined, env-CA tools still work and only Go + // tools lose brokering. securityd stays blocked either way, so the login token stays unreadable. + if local.CADir != "" { + if sandboxEnabled { + spec.AllowTrustd = true + } + switch installed, terr := ensureCATrusted(agentproxy.LocalCACertPath(local.CADir)); { + case terr != nil: + util.PrintWarning(fmt.Sprintf("Unable to add the agent proxy CA to your login keychain (%v). Most tools will still work, but some may report a certificate error.", terr)) + case installed: + util.PrintWarning("Added the Infisical agent proxy CA to your login keychain. This is one-time and persists for future runs.") + } + } + + // Hard fence: proxy on a unix socket in the tempdir, reached via the bridge. Otherwise: TCP loopback. + var listener net.Listener + var childProxyURL string + if pre.UsesBridge { + spec.ProxySocket = filepath.Join(tempDir, "proxy.sock") + listener, err = net.Listen("unix", spec.ProxySocket) + if err != nil { + fail(err, "Unable to start the local proxy") + } + spec.LoopbackPort = sandbox.BridgeLoopbackPort + childProxyURL = fmt.Sprintf("http://127.0.0.1:%d", sandbox.BridgeLoopbackPort) + } else { + listener, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fail(err, "Unable to start the local proxy") + } + spec.LoopbackPort = listener.Addr().(*net.TCPAddr).Port + childProxyURL = localProxyURL(listener.Addr().String()) + } + + caPath := filepath.Join(tempDir, "local-ca.pem") + if err := os.WriteFile(caPath, proxy.LocalRootPEM(), 0o600); err != nil { + fail(err, "Unable to write the CA certificate") + } + + proxyErrCh := make(chan error, 1) + go func() { proxyErrCh <- proxy.Serve(listener) }() + + spec.Env = buildLocalAgentEnv(cmd, childProxyURL, caPath, placeholders) + + if !sandboxEnabled { + util.PrintWarning("Running without the OS sandbox. The agent can read your keyring and credential files and reach the network directly. Credentials are still brokered on the wire.") + } + if logFile != "" { + fmt.Fprintln(os.Stderr, color.HiBlackString("proxy activity log: "+logFile)) + } + + child, err := backend.Wrap(spec, args) + if err != nil { + shutdownProxy(proxy) + fail(err, "Unable to start the agent in the sandbox") + } + + exitCode := runSandboxedChild(child, proxy, proxyErrCh) + shutdownProxy(proxy) + cleanup() + os.Exit(exitCode) +} + +// runSandboxedChild starts the child, forwards signals, and returns its exit code; if the proxy dies +// first it kills the child. +func runSandboxedChild(child *exec.Cmd, proxy *agentproxy.Proxy, proxyErrCh <-chan error) int { + fmt.Fprintln(os.Stderr, color.GreenString("Starting agent behind the Infisical agent proxy (sandboxed)")) + + if err := child.Start(); err != nil { + fmt.Fprintf(os.Stderr, "failed to start the agent process: %v\n", err) + return 1 + } + + stopForwarding := sandbox.ForwardTerminationSignals(child) + + waitCh := make(chan error, 1) + go func() { waitCh <- child.Wait() }() + + select { + case err := <-proxyErrCh: + fmt.Fprintf(os.Stderr, "%s\n", color.RedString("the ephemeral proxy stopped unexpectedly (%v); terminating the agent", err)) + if child.Process != nil { + _ = child.Process.Kill() + } + <-waitCh + return 1 + case err := <-waitCh: + stopForwarding() + code, ok := sandbox.WaitExitCode(err) + if !ok { + fmt.Fprintf(os.Stderr, "agent process error: %v\n", err) + } + return code + } +} + +func shutdownProxy(proxy *agentproxy.Proxy) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Shutdown(ctx) +} + +// resolveSandboxEnabled reads the toggle from flag or env only, never .infisical.json (a committed +// file must not be able to silently disable the boundary). +func resolveSandboxEnabled(cmd *cobra.Command) bool { + if cmd.Flags().Changed("sandbox") { + v, _ := cmd.Flags().GetBool("sandbox") + return v + } + if cmd.Flags().Changed("no-sandbox") { + v, _ := cmd.Flags().GetBool("no-sandbox") + return !v + } + if v := os.Getenv("INFISICAL_AGENT_PROXY_SANDBOX"); v != "" { + return !(v == "0" || strings.EqualFold(v, "false") || strings.EqualFold(v, "off")) + } + return true +} + +// tokenSource is the parent-held identity for a run. label names it in activity records. +type tokenSource struct { + token func() string + label string +} + +// resolveDeveloperTokenSource resolves the run's identity: --token if given, else the keyring login. +// There is no machine-identity path; that is for services, not a person on a laptop. Neither source +// refreshes mid-session, so we fail fast on an already-expired token. +func resolveDeveloperTokenSource(cmd *cobra.Command) tokenSource { + if token, err := util.GetInfisicalToken(cmd); err == nil && token != nil && token.Token != "" { + failIfTokenExpired(token.Token, "the provided token") + return tokenSource{token: func() string { return token.Token }, label: "token"} + } + + details, err := util.GetCurrentLoggedInUserDetails(true) + if err != nil || !details.IsUserLoggedIn || details.LoginExpired { + details = util.EstablishUserLoginSession() + } + if details.UserCredentials.JTWToken == "" { + util.HandleError(fmt.Errorf("could not resolve your Infisical login; run 'infisical login' or pass --token")) + } + jwt := details.UserCredentials.JTWToken + failIfTokenExpired(jwt, "your login") + return tokenSource{token: func() string { return jwt }, label: details.UserCredentials.Email} +} + +// failIfTokenExpired aborts with a clear error when the token is already expired; silent otherwise +// (no routine "expires in Xh" banner). Tokens that aren't readable JWTs (e.g. service tokens) pass. +func failIfTokenExpired(jwtToken, subject string) { + if exp, ok := jwtExpiry(jwtToken); ok && time.Until(exp) <= 0 { + util.HandleError(fmt.Errorf("%s has expired; brokering would fail. Run 'infisical login' or pass a valid --token", subject)) + } +} + +// jwtExpiry reads the exp claim unverified; false if the token isn't a readable JWT (e.g. a service token). +func jwtExpiry(token string) (time.Time, bool) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{}, false + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{}, false + } + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp == 0 { + return time.Time{}, false + } + return time.Unix(claims.Exp, 0), true +} + +// fetchLocalProxiedServiceConfig returns the placeholder env to inject. No CanProxy filter (locally +// the gate is Read Value); disabled services are skipped. Real secret values are never fetched here. +func fetchLocalProxiedServiceConfig(httpClient *resty.Client, projectID, environment, secretPath string) map[string]string { + resp, err := api.CallListProxiedServices(httpClient, api.ListProxiedServicesRequest{ + ProjectID: projectID, + Environment: environment, + SecretPath: secretPath, + }) + if err != nil { + util.HandleError(err, "Failed to list proxied services") + } + + placeholders := map[string]string{} + for _, svc := range resp.Services { + if !svc.IsEnabled { + continue + } + for _, cred := range svc.Credentials { + if cred.Role == "credential-substitution" && cred.PlaceholderKey != "" { + placeholders[cred.PlaceholderKey] = cred.PlaceholderValue + } + } + } + return placeholders +} + +// defaultAgentStateWritePaths returns the supported agents' state paths under home that exist (only +// existing ones: bwrap --bind fails on a missing source). +func defaultAgentStateWritePaths(home string) []string { + if home == "" { + return nil + } + candidates := []string{ + filepath.Join(home, ".claude"), // Claude Code state dir (sessions, history, cache) + filepath.Join(home, ".claude.json"), // Claude Code top-level config + filepath.Join(home, ".codex"), // Codex state dir + } + var out []string + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + out = append(out, p) + } + } + return out +} + +// hostRuntimeDir is the per-user runtime directory holding host IPC sockets (the session bus and +// friends). Linux only: XDG_RUNTIME_DIR is scrubbed from the child, but the directory itself is still +// on disk, and unix sockets ignore the network namespace, so it is masked too. +func hostRuntimeDir() string { + if runtime.GOOS != "linux" { + return "" + } + if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" { + return d + } + return fmt.Sprintf("/run/user/%d", os.Getuid()) +} + +// absolutePaths resolves against the cwd and drops empties. SBPL `subpath` and bwrap `--ro-bind` both +// reject relative paths, so --allow-read ./creds would otherwise build a profile that fails to load. +func absolutePaths(paths []string) []string { + out := make([]string, 0, len(paths)) + for _, p := range paths { + if p == "" { + continue + } + abs, err := filepath.Abs(p) + if err != nil { + abs = p + } + out = append(out, abs) + } + return out +} + +// localProxyURL is the child's proxy URL: no userinfo, so no credential reaches the child. +func localProxyURL(proxyAddr string) string { + u := url.URL{Scheme: "http", Host: proxyAddr} + return u.String() +} + +// buildLocalAgentEnv builds the child env: a scrubbed parent env (no INFISICAL_TOKEN/DOMAIN, no +// secret-shaped vars) plus the credential-free proxy vars, CA trust vars, and placeholders. +func buildLocalAgentEnv(cmd *cobra.Command, proxy, caPath string, placeholders map[string]string) []string { + passEnv, _ := cmd.Flags().GetStringArray("pass-env") + setEnv, _ := cmd.Flags().GetStringArray("set-env") + + allowlist := map[string]bool{} + for _, k := range passEnv { + allowlist[k] = true + } + + stale := map[string]bool{} + for _, k := range proxyEnvKeys { + stale[k] = true + } + for _, k := range credentialEnvKeys { + stale[k] = true + } + for _, k := range authAgentEnvKeys { + stale[k] = true + } + stale[util.INFISICAL_TOKEN_NAME] = true + // INFISICAL_JWT authenticates a JWT/OIDC machine identity, so it is as good as a token here and + // matches none of the secret-shaped name patterns. + stale[util.INFISICAL_JWT_NAME] = true + stale[util.INFISICAL_OIDC_AUTH_JWT_NAME] = true + stale[util.INFISICAL_DOMAIN_ENV_NAME] = true + stale[util.INFISICAL_VAULT_FILE_PASSPHRASE_ENV_NAME] = true + + var operatorNoProxy []string + env := map[string]string{} + for _, kv := range os.Environ() { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + continue + } + key, val := parts[0], parts[1] + if key == "NO_PROXY" || key == "no_proxy" { + operatorNoProxy = append(operatorNoProxy, val) + continue + } + if allowlist[key] { + env[key] = val + continue + } + if stale[key] || isSecretShapedEnvName(key) { + continue + } + env[key] = val + } + + setProxyEnv(env, proxy, mergeNoProxy(operatorNoProxy...)) + + for _, k := range caTrustEnvVars { + env[k] = caPath + } + + for k, v := range placeholders { + env[k] = v + } + + // --set-env wins last so operators can force a literal value into the child. + for _, kv := range setEnv { + parts := strings.SplitN(kv, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } + } + + result := make([]string, 0, len(env)) + for k, v := range env { + result = append(result, fmt.Sprintf("%s=%s", k, v)) + } + return result +} + +func isSecretShapedEnvName(name string) bool { + upper := strings.ToUpper(name) + for _, s := range secretShapedEnvSubstrings { + if strings.Contains(upper, s) { + return true + } + } + return false +} + +func init() { + agentProxyRunCmd.Flags().StringP("env", "e", "", "environment slug to fetch proxied services and secrets from (falls back to INFISICAL_ENVIRONMENT or .infisical.json)") + agentProxyRunCmd.Flags().String("path", "/", "secret path (folder) to fetch from (falls back to INFISICAL_SECRET_PATH or defaultSecretPath in .infisical.json)") + agentProxyRunCmd.Flags().String("projectId", "", "project id (falls back to INFISICAL_PROJECT_ID or .infisical.json)") + agentProxyRunCmd.Flags().String("token", "", "run using this token instead of your logged-in session") + agentProxyRunCmd.Flags().Bool("sandbox", true, "run the agent inside the OS sandbox") + agentProxyRunCmd.Flags().Bool("no-sandbox", false, "disable the OS sandbox; the agent can then read your files and reach the network directly") + agentProxyRunCmd.Flags().String("unmatched-host", "allow", "policy for hosts with no proxied service: allow | block") + agentProxyRunCmd.Flags().Int("poll-interval", 60, "interval in seconds to refresh permissions and credentials") + agentProxyRunCmd.Flags().String("log-file", "", "write the proxy activity log to this path instead of a temporary file") + agentProxyRunCmd.Flags().StringArray("allow-read", nil, "allow the agent to read a path the sandbox denies by default (can be specified multiple times)") + agentProxyRunCmd.Flags().StringArray("allow-write", nil, "allow the agent to write a path (can be specified multiple times)") + agentProxyRunCmd.Flags().StringArray("allow-host", nil, "allow the agent to reach a host that has no proxied service (can be specified multiple times)") + agentProxyRunCmd.Flags().StringArray("pass-env", nil, "pass one of your environment variables through to the agent (can be specified multiple times)") + agentProxyRunCmd.Flags().StringArray("set-env", nil, "set an environment variable in the agent as KEY=VALUE (can be specified multiple times)") + // --proxy exists only so we can reject it with a helpful message pointing at connect. + agentProxyRunCmd.Flags().String("proxy", "", "") + _ = agentProxyRunCmd.Flags().MarkHidden("proxy") + + agentProxyCmd.AddCommand(agentProxyRunCmd) +} diff --git a/packages/cmd/agent_proxy_run_test.go b/packages/cmd/agent_proxy_run_test.go new file mode 100644 index 00000000..3a1c91c1 --- /dev/null +++ b/packages/cmd/agent_proxy_run_test.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func newRunTestCmd() *cobra.Command { + c := &cobra.Command{Use: "run"} + c.Flags().StringArray("pass-env", nil, "") + c.Flags().StringArray("set-env", nil, "") + return c +} + +func envToMap(env []string) map[string]string { + m := map[string]string{} + for _, kv := range env { + parts := strings.SplitN(kv, "=", 2) + if len(parts) == 2 { + m[parts[0]] = parts[1] + } + } + return m +} + +func TestBuildLocalAgentEnvScrubsSecretsAndToken(t *testing.T) { + t.Setenv("INFISICAL_TOKEN", "dev.jwt.secret") + t.Setenv("INFISICAL_DOMAIN", "https://app.infisical.com/api") + t.Setenv("AWS_SECRET_ACCESS_KEY", "should-be-scrubbed") + t.Setenv("ANTHROPIC_API_KEY", "sk-should-be-scrubbed") + t.Setenv("MY_PASSWORD", "hunter2") + t.Setenv("HARMLESS", "keep-me") + + cmd := newRunTestCmd() + placeholders := map[string]string{"STRIPE_KEY": "__PLACEHOLDER__"} + env := envToMap(buildLocalAgentEnv(cmd, "http://127.0.0.1:51234", "/tmp/ca.pem", placeholders)) + + // The developer token must never reach the child, by name or by value anywhere. + if _, ok := env["INFISICAL_TOKEN"]; ok { + t.Error("INFISICAL_TOKEN must be scrubbed from the child env") + } + if _, ok := env["INFISICAL_DOMAIN"]; ok { + t.Error("INFISICAL_DOMAIN must be scrubbed from the child env") + } + for k, v := range env { + if strings.Contains(v, "dev.jwt.secret") { + t.Errorf("developer token leaked into child env var %q=%q", k, v) + } + } + + for _, k := range []string{"AWS_SECRET_ACCESS_KEY", "ANTHROPIC_API_KEY", "MY_PASSWORD"} { + if _, ok := env[k]; ok { + t.Errorf("secret-shaped var %q must be scrubbed", k) + } + } + + if env["HARMLESS"] != "keep-me" { + t.Error("non-secret vars must be preserved") + } + // Both cases must be set: curl honours only lowercase http_proxy for plain-HTTP URLs, so an + // uppercase-only env would send those requests to DNS instead of the proxy. + for _, k := range []string{"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"} { + if env[k] != "http://127.0.0.1:51234" { + t.Errorf("%s = %q; want the credential-free proxy URL", k, env[k]) + } + } + if env["no_proxy"] != env["NO_PROXY"] { + t.Errorf("no_proxy (%q) must match NO_PROXY (%q)", env["no_proxy"], env["NO_PROXY"]) + } + if !strings.Contains(env["HTTPS_PROXY"], "127.0.0.1") || strings.Contains(env["HTTPS_PROXY"], "@") { + t.Errorf("proxy URL must be credential-free loopback, got %q", env["HTTPS_PROXY"]) + } + if env["SSL_CERT_FILE"] != "/tmp/ca.pem" || env["NODE_EXTRA_CA_CERTS"] != "/tmp/ca.pem" { + t.Error("CA trust vars must point at the local CA cert") + } + if env["STRIPE_KEY"] != "__PLACEHOLDER__" { + t.Error("placeholder must be injected") + } + if !strings.Contains(env["NO_PROXY"], "127.0.0.1") { + t.Errorf("NO_PROXY must include localhost, got %q", env["NO_PROXY"]) + } +} + +func TestBuildLocalAgentEnvPassEnvOverridesScrub(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "sk-real") + cmd := newRunTestCmd() + _ = cmd.Flags().Set("pass-env", "ANTHROPIC_API_KEY") + + env := envToMap(buildLocalAgentEnv(cmd, "http://127.0.0.1:1", "/tmp/ca.pem", nil)) + if env["ANTHROPIC_API_KEY"] != "sk-real" { + t.Errorf("--pass-env must re-admit a scrubbed var, got %q", env["ANTHROPIC_API_KEY"]) + } +} + +func TestBuildLocalAgentEnvSetEnvWins(t *testing.T) { + cmd := newRunTestCmd() + _ = cmd.Flags().Set("set-env", "FEATURE_FLAG=on") + env := envToMap(buildLocalAgentEnv(cmd, "http://127.0.0.1:1", "/tmp/ca.pem", nil)) + if env["FEATURE_FLAG"] != "on" { + t.Errorf("--set-env must inject a literal, got %q", env["FEATURE_FLAG"]) + } +} + +func TestLocalProxyURLHasNoCredential(t *testing.T) { + u := localProxyURL("127.0.0.1:51234") + if u != "http://127.0.0.1:51234" { + t.Fatalf("local proxy URL = %q; want a bare loopback URL with no userinfo", u) + } +} + +func TestDefaultAgentStateWritePaths(t *testing.T) { + home := t.TempDir() + // Create ~/.claude (dir) and ~/.claude.json (file); leave ~/.codex absent. + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".claude.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + + got := defaultAgentStateWritePaths(home) + has := func(p string) bool { + for _, g := range got { + if g == filepath.Join(home, p) { + return true + } + } + return false + } + if !has(".claude") || !has(".claude.json") { + t.Errorf("expected existing agent paths to be included, got %v", got) + } + if has(".codex") { + t.Errorf("non-existent ~/.codex must NOT be included (bwrap --bind would fail), got %v", got) + } + if defaultAgentStateWritePaths("") != nil { + t.Error("empty home must return nil") + } +} + +func TestIsSecretShapedEnvName(t *testing.T) { + shaped := []string{"GITHUB_TOKEN", "aws_secret_access_key", "DB_PASSWORD", "X_API_KEY", "MY_CREDENTIAL"} + for _, n := range shaped { + if !isSecretShapedEnvName(n) { + t.Errorf("%q should be treated as secret-shaped", n) + } + } + plain := []string{"HOME", "PATH", "TERM", "LANG", "EDITOR"} + for _, n := range plain { + if isSecretShapedEnvName(n) { + t.Errorf("%q should NOT be treated as secret-shaped", n) + } + } +} diff --git a/packages/cmd/agent_proxy_run_token_test.go b/packages/cmd/agent_proxy_run_token_test.go new file mode 100644 index 00000000..42a07b9d --- /dev/null +++ b/packages/cmd/agent_proxy_run_token_test.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "encoding/base64" + "encoding/json" + "testing" + "time" +) + +func makeJWT(t *testing.T, claims map[string]any) string { + t.Helper() + enc := func(v any) string { + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(b) + } + return enc(map[string]any{"alg": "HS256", "typ": "JWT"}) + "." + enc(claims) + ".sig" +} + +func TestJwtExpiryReadsExp(t *testing.T) { + want := time.Now().Add(2 * time.Hour).Unix() + exp, ok := jwtExpiry(makeJWT(t, map[string]any{"exp": want})) + if !ok { + t.Fatal("expected exp to be readable") + } + if exp.Unix() != want { + t.Fatalf("exp = %d, want %d", exp.Unix(), want) + } +} + +func TestJwtExpiryRejectsNonJWT(t *testing.T) { + for _, tok := range []string{ + "st.abc.def.ghi", // service token (not a JWT) + "not-a-jwt", // no dots + "a.b", // too few segments + makeJWT(t, map[string]any{}), // JWT without an exp claim + } { + if _, ok := jwtExpiry(tok); ok { + t.Errorf("expected %q to have no readable expiry", tok) + } + } +} diff --git a/packages/cmd/agent_proxy_run_trust_darwin.go b/packages/cmd/agent_proxy_run_trust_darwin.go new file mode 100644 index 00000000..f59f8ebb --- /dev/null +++ b/packages/cmd/agent_proxy_run_trust_darwin.go @@ -0,0 +1,39 @@ +//go:build darwin + +package cmd + +import ( + "os/exec" +) + +// ensureCATrusted makes the local root a trusted anchor in the login keychain, so Go tools like gh +// accept the proxy's leaves. Silent if already trusted; installing triggers a one-time password +// prompt. Affects cert trust only: securityd stays blocked, so keychain secrets remain unreadable. +func ensureCATrusted(certPath string) (bool, error) { + if trustSettingsPresent(certPath) { + return false, nil + } + // add-trusted-cert into the user (login) keychain; -r trustRoot marks it a trusted anchor. + // #nosec G204 -- certPath is a path we control under ~/.infisical + cmd := exec.Command("security", "add-trusted-cert", "-r", "trustRoot", certPath) + if out, err := cmd.CombinedOutput(); err != nil { + return false, &trustInstallError{out: string(out), err: err} + } + return true, nil +} + +// trustSettingsPresent reports whether the cert already has trust settings (i.e. is a trusted anchor), +// without prompting. `security verify-cert` succeeds only if the chain is trusted. +func trustSettingsPresent(certPath string) bool { + // #nosec G204 -- certPath is a path we control + return exec.Command("security", "verify-cert", "-c", certPath).Run() == nil +} + +type trustInstallError struct { + out string + err error +} + +func (e *trustInstallError) Error() string { + return "failed to trust the local CA in the keychain: " + e.err.Error() + " (" + e.out + ")" +} diff --git a/packages/cmd/agent_proxy_run_trust_other.go b/packages/cmd/agent_proxy_run_trust_other.go new file mode 100644 index 00000000..2dc0b737 --- /dev/null +++ b/packages/cmd/agent_proxy_run_trust_other.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package cmd + +// ensureCATrusted is a no-op off macOS: only macOS needs a keychain-trusted anchor for native-trust +// clients. Elsewhere (Linux) the injected CA env var is enough. +func ensureCATrusted(string) (bool, error) { return false, nil } diff --git a/packages/cmd/sandbox_supervisor.go b/packages/cmd/sandbox_supervisor.go new file mode 100644 index 00000000..ec88e8e3 --- /dev/null +++ b/packages/cmd/sandbox_supervisor.go @@ -0,0 +1,34 @@ +//go:build linux + +package cmd + +import ( + "os" + + "github.com/Infisical/infisical-merge/packages/sandbox" + "github.com/spf13/cobra" +) + +// sandboxSupervisorCmd is an internal, hidden entry point: the Linux hard-fence path re-execs the CLI +// as this subcommand inside the bwrap netns to run the in-namespace bridge. Not for users. +var sandboxSupervisorCmd = &cobra.Command{ + Use: sandbox.SupervisorSubcommand, + Hidden: true, + DisableFlagsInUseLine: true, + // No-op override of root's PersistentPreRun: skip the update check / notices / keyring read (a + // network call) that would otherwise run inside the network-less namespace. + PersistentPreRun: func(_ *cobra.Command, _ []string) {}, + Run: func(cmd *cobra.Command, args []string) { + probe, _ := cmd.Flags().GetBool("probe") + port, _ := cmd.Flags().GetInt("port") + socket, _ := cmd.Flags().GetString("socket") + os.Exit(sandbox.RunSupervisor(probe, port, socket, args)) + }, +} + +func init() { + sandboxSupervisorCmd.Flags().Bool("probe", false, "capability probe: bring loopback up and exit") + sandboxSupervisorCmd.Flags().Int("port", 0, "loopback port to bridge from") + sandboxSupervisorCmd.Flags().String("socket", "", "proxy unix socket to bridge to") + RootCmd.AddCommand(sandboxSupervisorCmd) +} diff --git a/packages/sandbox/bridge.go b/packages/sandbox/bridge.go new file mode 100644 index 00000000..f2110f52 --- /dev/null +++ b/packages/sandbox/bridge.go @@ -0,0 +1,130 @@ +//go:build linux + +package sandbox + +import ( + "fmt" + "io" + "net" + "os" + "sync" + + "golang.org/x/sys/unix" +) + +// RunSupervisor runs inside the bwrap netns on the hard-fence path: it brings loopback up, bridges +// 127.0.0.1:port to the parent's proxy unix socket, then execs the agent. probe=true is Preflight's +// capability check: bring loopback up and return without bridging or exec'ing. +func RunSupervisor(probe bool, port int, socket string, argv []string) int { + if err := bringLoopbackUp(); err != nil { + if probe { + return 1 + } + fmt.Fprintf(os.Stderr, "sandbox supervisor: failed to bring up loopback: %v\n", err) + return 1 + } + if probe { + return 0 + } + + if port == 0 || socket == "" || len(argv) == 0 { + fmt.Fprintln(os.Stderr, "sandbox supervisor: --port, --socket and a command are required") + return 1 + } + + // Fail loud: if the port is already taken, abort rather than route the agent's traffic to it. + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + fmt.Fprintf(os.Stderr, "sandbox supervisor: failed to bind loopback proxy port %d: %v\n", port, err) + return 1 + } + go runBridge(ln, socket) + + agent := newInheritedCmd(argv, os.Environ()) + if err := agent.Start(); err != nil { + fmt.Fprintf(os.Stderr, "sandbox supervisor: failed to start the agent: %v\n", err) + return 1 + } + + stopForwarding := ForwardTerminationSignals(agent) + err = agent.Wait() + stopForwarding() + + code, ok := WaitExitCode(err) + if !ok { + fmt.Fprintf(os.Stderr, "sandbox supervisor: agent error: %v\n", err) + } + return code +} + +// runBridge forwards each loopback TCP connection to the proxy's unix socket. The socket must be a +// pathname socket (abstract sockets are netns-scoped and unreachable). +func runBridge(ln net.Listener, socket string) { + defer ln.Close() + for { + conn, err := ln.Accept() + if err != nil { + return + } + go bridgeConn(conn, socket) + } +} + +func bridgeConn(client net.Conn, socket string) { + defer client.Close() + upstream, err := net.Dial("unix", socket) + if err != nil { + return + } + defer upstream.Close() + + // Wait for BOTH directions. Tearing down as soon as either copy finished would truncate a response + // still streaming back to a client that had already half-closed its request side. + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); copyThenCloseWrite(upstream, client) }() + go func() { defer wg.Done(); copyThenCloseWrite(client, upstream) }() + wg.Wait() +} + +// copyThenCloseWrite copies src to dst, then half-closes dst so the peer sees EOF for this direction +// while the other keeps flowing. TCPConn and UnixConn both implement CloseWrite. +func copyThenCloseWrite(dst, src net.Conn) { + _, _ = io.Copy(dst, src) + if cw, ok := dst.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } +} + +// bringLoopbackUp makes sure lo is UP so 127.0.0.1 works inside the netns. bwrap normally raises it +// already, so this usually just confirms. We only report failure if lo is genuinely still down: the +// SIOCSIFFLAGS write gets EPERM in a user-namespaced netns even with CAP_NET_ADMIN, and treating that +// as fatal would push every Linux run onto the weaker shared-net path. +func bringLoopbackUp() error { + fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0) + if err != nil { + return err + } + defer unix.Close(fd) + + ifr, err := unix.NewIfreq("lo") + if err != nil { + return err + } + if err := unix.IoctlIfreq(fd, unix.SIOCGIFFLAGS, ifr); err != nil { + return err + } + if ifr.Uint16()&unix.IFF_UP != 0 { + return nil + } + flags := ifr.Uint16() | unix.IFF_UP | unix.IFF_RUNNING + ifr.SetUint16(flags) + if err := unix.IoctlIfreq(fd, unix.SIOCSIFFLAGS, ifr); err != nil { + // Re-read: some kernels refuse the write yet lo is up anyway. Trust the observed state. + if unix.IoctlIfreq(fd, unix.SIOCGIFFLAGS, ifr) == nil && ifr.Uint16()&unix.IFF_UP != 0 { + return nil + } + return err + } + return nil +} diff --git a/packages/sandbox/bwrap.go b/packages/sandbox/bwrap.go new file mode 100644 index 00000000..990d44e0 --- /dev/null +++ b/packages/sandbox/bwrap.go @@ -0,0 +1,105 @@ +//go:build linux + +package sandbox + +import ( + "fmt" + "os" + "os/exec" +) + +func osBackend() Backend { return bwrapBackend{} } + +// statDenyPath reports whether a deny path exists and whether it is a regular file. buildBwrapArgv +// skips missing paths: nothing to hide, and the mountpoint cannot be created under the ro root bind. +func statDenyPath(path string) (exists, isFile bool) { + info, err := os.Stat(path) + if err != nil { + return false, false + } + return true, info.Mode().IsRegular() +} + +type bwrapBackend struct{} + +// Preflight checks bwrap is present and probes (by executing an empty-netns bwrap) whether the hard +// fence works here; if not (e.g. Ubuntu 24.04 userns restriction) it falls back to shared net. +func (bwrapBackend) Preflight(spec Spec) (PreflightResult, error) { + bwrapPath, err := exec.LookPath("bwrap") + if err != nil { + return PreflightResult{ + Supported: false, + Reason: "bubblewrap is not installed; install it with your package manager or re-run with --no-sandbox", + }, nil + } + + if spec.NetMode == SharedNet { + // Caller already asked for shared net; nothing to probe. + return PreflightResult{Supported: true, UsesBridge: false}, nil + } + + if hardFenceWorks(bwrapPath) { + return PreflightResult{Supported: true, UsesBridge: true}, nil + } + + // Before downgrading, check whether bwrap can make a user namespace at all. If it cannot (stock + // Ubuntu 24.04 restricts this), the shared-net fallback would also die at "setting up uid map", so + // falling back would swap a clear error for a raw bwrap crash. Report unsupported instead. + if !sharedNetWorks(bwrapPath) { + return PreflightResult{ + Supported: false, + Reason: "the OS sandbox cannot start because unprivileged user namespaces are restricted on this host; " + + "allow them in your system settings or re-run with --no-sandbox", + }, nil + } + + // User namespaces work but the empty-netns hard fence does not. Fall back to shared host networking. + // Credential controls are unaffected; only the network fence weakens. + return PreflightResult{ + Supported: true, + FallbackToSharedNet: true, + UsesBridge: false, + Reason: "a private network namespace is unavailable on this host", + }, nil +} + +// probeBwrap runs bwrap with the shared base flags plus extra, and reports whether it exited cleanly. +func probeBwrap(bwrapPath string, extra []string, command ...string) bool { + args := append(bwrapBaseArgs(), extra...) + args = append(append(args, "--"), command...) + // #nosec G204 -- base flags are fixed and the command is one of this file's own literals + return exec.Command(bwrapPath, args...).Run() == nil +} + +// sharedNetWorks probes whether bwrap can start a user-namespaced sandbox that shares host networking. +// It reuses the real argv's base flags, so a success here means the fallback can run. +func sharedNetWorks(bwrapPath string) bool { + return probeBwrap(bwrapPath, []string{"--share-net"}, "true") +} + +// hardFenceWorks probes whether an empty-netns bwrap can start and bring loopback up. +func hardFenceWorks(bwrapPath string) bool { + self, err := os.Executable() + if err != nil { + return false + } + return probeBwrap(bwrapPath, nil, self, SupervisorSubcommand, "--probe") +} + +func (bwrapBackend) Wrap(spec Spec, argv []string) (*exec.Cmd, error) { + if len(argv) == 0 { + return nil, errEmptyCommand + } + bwrapPath, err := exec.LookPath("bwrap") + if err != nil { + return nil, fmt.Errorf("bubblewrap (bwrap) is not installed: %w", err) + } + self, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("cannot resolve own executable for the sandbox supervisor: %w", err) + } + + full := buildBwrapArgv(spec, self, argv, statDenyPath) + full[0] = bwrapPath + return newInheritedCmd(full, spec.Env), nil +} diff --git a/packages/sandbox/bwrap_args.go b/packages/sandbox/bwrap_args.go new file mode 100644 index 00000000..522f43af --- /dev/null +++ b/packages/sandbox/bwrap_args.go @@ -0,0 +1,104 @@ +// This file is deliberately NOT behind a //go:build linux tag, even though only the Linux backend +// calls into it. Keeping it buildable everywhere is what lets bwrap_args_test.go verify the argv (and +// its load-bearing mount ordering) from a Mac. The cost is that `staticcheck` on darwin reports these +// as unused, since it discounts test-only callers. Do not "fix" that by adding a build tag. +package sandbox + +import "strconv" + +// BridgeLoopbackPort is where the in-namespace bridge listens. A fixed port is safe because each +// hard-fence run gets its own empty netns. +const BridgeLoopbackPort = 17321 + +// SupervisorSubcommand re-enters this binary as the in-namespace supervisor. Exported so cmd can +// register the matching hidden command. +const SupervisorSubcommand = "__sandbox-supervisor" + +// bwrapBaseArgs are the containment flags shared by the real argv and by Preflight's probes, so a +// probe can never certify flags the real run does not use. +// +// --new-session puts the agent in its own session, which blocks the TIOCSTI ioctl. Without it a +// sandboxed process can push characters into the terminal's input queue and the shell runs them after +// the sandbox exits, escaping entirely. Verified on kernel 5.15, where TIOCSTI still exists (6.2 added +// the dev.tty.legacy_tiocsti switch that disables it). It costs the controlling terminal, not stdio: +// stdin and stdout stay ttys, and TUIs read keys in raw mode, so Claude Code and Ctrl-C still work. +func bwrapBaseArgs() []string { + return []string{ + "--new-session", + "--unshare-all", + "--die-with-parent", + "--ro-bind", "/", "/", + "--dev", "/dev", + "--proc", "/proc", + "--tmpfs", "/tmp", + } +} + +// buildBwrapArgv builds the bwrap argv for a spec. The filesystem dependency is injected via classify +// (does this deny path exist, and is it a regular file?), which keeps the function pure and testable +// on any platform. Mount order below is load-bearing; each step says why. +func buildBwrapArgv(spec Spec, selfExe string, argv []string, classify func(string) (exists, isFile bool)) []string { + args := append([]string{"bwrap"}, bwrapBaseArgs()...) + + if spec.NetMode == SharedNet { + args = append(args, "--share-net") + } + + // Write binds come after --tmpfs /tmp, or a tempdir under /tmp would be shadowed by it. + if spec.Cwd != "" { + args = append(args, "--bind", spec.Cwd, spec.Cwd) + } + if spec.TempDir != "" { + args = append(args, "--bind", spec.TempDir, spec.TempDir) + } + for _, p := range dedupeSorted(spec.WritePaths) { + args = append(args, "--bind", p, p) + } + + // Deny mounts come after the write binds so they win. Otherwise running the agent from $HOME would + // bind $HOME writable and re-expose ~/.aws, ~/.ssh and friends underneath it. + exceptions := dedupeSorted(spec.ReadPaths) + var maskedDirs []string + for _, p := range dedupeSorted(spec.DenyPaths) { + // Left unmasked rather than masked-then-rebound, which would depend on how bwrap resolves an + // already-shadowed source. + if containsString(exceptions, p) { + continue + } + exists, isFile := classify(p) + if !exists { + continue // bwrap cannot create a mountpoint under the read-only root bind, and would abort + } + if isFile { + args = append(args, "--ro-bind", "/dev/null", p) // tmpfs onto a file aborts with ENOTDIR + } else { + args = append(args, "--tmpfs", p) + maskedDirs = append(maskedDirs, p) + } + } + + // Read exceptions land last so no mask re-covers them. Their parent is now an empty tmpfs, which is + // writable, so bwrap can create the mountpoint. + for _, p := range exceptions { + if !underAny(p, maskedDirs) { + continue // nothing masks it: the root bind already exposes it + } + if exists, _ := classify(p); !exists { + continue // --ro-bind fails on a missing source + } + args = append(args, "--ro-bind", p, p) + } + + args = append(args, "--") + + if spec.NetMode == HardFence && spec.ProxySocket != "" { + args = append(args, + selfExe, SupervisorSubcommand, + "--port", strconv.Itoa(spec.LoopbackPort), + "--socket", spec.ProxySocket, + "--", + ) + } + + return append(args, argv...) +} diff --git a/packages/sandbox/bwrap_args_test.go b/packages/sandbox/bwrap_args_test.go new file mode 100644 index 00000000..c81717f9 --- /dev/null +++ b/packages/sandbox/bwrap_args_test.go @@ -0,0 +1,264 @@ +package sandbox + +import ( + "strings" + "testing" +) + +func bwrapSpec(net NetMode) Spec { + return Spec{ + Enabled: true, + Cwd: "/home/dev/project", + TempDir: "/tmp/infisical-run-xyz", + LoopbackPort: BridgeLoopbackPort, + ProxySocket: "/tmp/infisical-run-xyz/proxy.sock", + DenyPaths: []string{"/home/dev/.aws", "/home/dev/.ssh", "/home/dev/.infisical"}, + WritePaths: []string{"/home/dev/.cache"}, + NetMode: net, + } +} + +// allExistingDirs classifies every deny path as an existing directory (never a file, never missing), +// so the default specs mask with --tmpfs. Tests that care about the file or missing branch pass their +// own classifier. +func allExistingDirs(string) (bool, bool) { return true, false } + +func argIndex(args []string, tok string) int { + for i, a := range args { + if a == tok { + return i + } + } + return -1 +} + +func TestBwrapArgvHardFence(t *testing.T) { + args := buildBwrapArgv(bwrapSpec(HardFence), "/proc/self/exe", []string{"claude", "--flag"}, allExistingDirs) + joined := strings.Join(args, " ") + + for _, want := range []string{ + "--new-session", "--unshare-all", "--die-with-parent", + "--tmpfs /home/dev/.aws", "--tmpfs /home/dev/.ssh", "--tmpfs /home/dev/.infisical", + "--bind /home/dev/project /home/dev/project", + "--bind /tmp/infisical-run-xyz /tmp/infisical-run-xyz", + "--bind /home/dev/.cache /home/dev/.cache", + SupervisorSubcommand, + "--socket /tmp/infisical-run-xyz/proxy.sock", + } { + if !strings.Contains(joined, want) { + t.Errorf("hard-fence argv missing %q\n%s", want, joined) + } + } + + // Must NOT share host net on the hard fence, and must NOT setsid. + if strings.Contains(joined, "--share-net") { + t.Error("hard fence must not pass --share-net") + } + // --new-session is what blocks TIOCSTI terminal injection; without it a sandboxed agent can queue + // keystrokes that the host shell runs after the sandbox exits. + if !strings.Contains(joined, "--new-session") { + t.Error("must pass --new-session, or the sandbox is escapable via TIOCSTI") + } + + // The supervisor + agent must appear after the -- separator, in order. + sep := argIndex(args, "--") + sup := argIndex(args, SupervisorSubcommand) + agent := argIndex(args, "claude") + if sep == -1 || sup < sep || agent < sup { + t.Fatalf("expected `-- ... claude`; sep=%d sup=%d agent=%d\n%v", sep, sup, agent, args) + } + // tempdir bind must come after the /tmp tmpfs so it is not shadowed. + tmpfsTmp := lastArgPairIndex(args, "--tmpfs", "/tmp") + bindTemp := lastArgPairIndex(args, "--bind", "/tmp/infisical-run-xyz") + if tmpfsTmp == -1 || bindTemp == -1 || bindTemp < tmpfsTmp { + t.Fatalf("tempdir bind must follow the /tmp tmpfs (tmpfs=%d bind=%d)", tmpfsTmp, bindTemp) + } +} + +func TestBwrapArgvSharedNet(t *testing.T) { + args := buildBwrapArgv(bwrapSpec(SharedNet), "/proc/self/exe", []string{"claude"}, allExistingDirs) + joined := strings.Join(args, " ") + + if !strings.Contains(joined, "--share-net") { + t.Error("shared-net argv must pass --share-net") + } + // No bridge/supervisor on the shared-net path: the agent runs directly. + if strings.Contains(joined, SupervisorSubcommand) { + t.Error("shared-net path must not insert the supervisor") + } + if args[len(args)-1] != "claude" { + t.Errorf("agent must be the final arg on the shared-net path, got %q", args[len(args)-1]) + } +} + +// lastArgPairIndex finds the index of `flag` immediately followed by `val`. +func lastArgPairIndex(args []string, flag, val string) int { + idx := -1 + for i := 0; i+1 < len(args); i++ { + if args[i] == flag && args[i+1] == val { + idx = i + } + } + return idx +} + +func TestBwrapArgvMasksFilesWithDevNull(t *testing.T) { + spec := bwrapSpec(HardFence) + spec.DenyPaths = []string{"/home/dev/.aws", "/home/dev/.docker/config.json", "/home/dev/.netrc"} + // Classify the two dotfiles as files; the .aws dir stays a directory. All three exist. + classify := func(p string) (bool, bool) { + return true, p == "/home/dev/.docker/config.json" || p == "/home/dev/.netrc" + } + joined := strings.Join(buildBwrapArgv(spec, "/proc/self/exe", []string{"claude"}, classify), " ") + + // Files masked by binding /dev/null over them (tmpfs onto a file aborts bwrap). + for _, want := range []string{ + "--ro-bind /dev/null /home/dev/.docker/config.json", + "--ro-bind /dev/null /home/dev/.netrc", + "--tmpfs /home/dev/.aws", // directory still uses tmpfs + } { + if !strings.Contains(joined, want) { + t.Errorf("expected %q\n%s", want, joined) + } + } + // A file deny path must NOT be masked with tmpfs. + if strings.Contains(joined, "--tmpfs /home/dev/.netrc") || strings.Contains(joined, "--tmpfs /home/dev/.docker/config.json") { + t.Errorf("file deny paths must not be masked with tmpfs\n%s", joined) + } +} + +func TestBwrapArgvSkipsMissingDenyPaths(t *testing.T) { + spec := bwrapSpec(HardFence) + spec.DenyPaths = []string{"/home/dev/.aws", "/home/dev/.ssh", "/home/dev/.netrc"} + // Only .aws exists (as a dir); .ssh and .netrc are missing on this account. + classify := func(p string) (bool, bool) { + return p == "/home/dev/.aws", false + } + joined := strings.Join(buildBwrapArgv(spec, "/proc/self/exe", []string{"claude"}, classify), " ") + + // The existing path is masked... + if !strings.Contains(joined, "--tmpfs /home/dev/.aws") { + t.Errorf("existing deny dir must be masked with tmpfs\n%s", joined) + } + // ...but missing paths must NOT appear at all: bwrap can't create a mountpoint under the + // read-only root bind, so a --tmpfs/--ro-bind for a missing path aborts the whole sandbox. + for _, missing := range []string{"/home/dev/.ssh", "/home/dev/.netrc"} { + if strings.Contains(joined, missing) { + t.Errorf("missing deny path %q must be skipped, not mounted\n%s", missing, joined) + } + } +} + +func TestBwrapArgvReadExceptionInsideDeniedDir(t *testing.T) { + spec := bwrapSpec(HardFence) + spec.DenyPaths = []string{"/home/dev/.aws", "/home/dev/.ssh"} + spec.ReadPaths = []string{"/home/dev/.aws/config"} + args := buildBwrapArgv(spec, "/proc/self/exe", []string{"claude"}, allExistingDirs) + joined := strings.Join(args, " ") + + // The directory is still masked, and the one excepted file is re-bound on top of that tmpfs. + if !strings.Contains(joined, "--tmpfs /home/dev/.aws") { + t.Errorf("the denied dir must still be masked\n%s", joined) + } + if !strings.Contains(joined, "--ro-bind /home/dev/.aws/config /home/dev/.aws/config") { + t.Errorf("the read exception must be re-bound read-only\n%s", joined) + } + // Ordering is what makes it work: the re-bind must land after the tmpfs that masks its parent. + mask := lastArgPairIndex(args, "--tmpfs", "/home/dev/.aws") + rebind := lastArgPairIndex(args, "--ro-bind", "/home/dev/.aws/config") + if mask == -1 || rebind == -1 || rebind < mask { + t.Fatalf("read exception must be emitted after the mask (mask=%d rebind=%d)\n%v", mask, rebind, args) + } + // It must be read-only: never bound writable. + if strings.Contains(joined, "--bind /home/dev/.aws/config") { + t.Errorf("a read exception must never be bound writable\n%s", joined) + } + // The unrelated deny is untouched. + if !strings.Contains(joined, "--tmpfs /home/dev/.ssh") { + t.Errorf("unrelated deny paths must stay masked\n%s", joined) + } +} + +func TestBwrapArgvReadExceptionOfWholeDenyPathSkipsMask(t *testing.T) { + spec := bwrapSpec(HardFence) + spec.DenyPaths = []string{"/home/dev/.aws", "/home/dev/.ssh"} + spec.ReadPaths = []string{"/home/dev/.aws"} + joined := strings.Join(buildBwrapArgv(spec, "/proc/self/exe", []string{"claude"}, allExistingDirs), " ") + + // Re-opening a deny path wholesale leaves it unmasked rather than masking then re-binding it. + if strings.Contains(joined, "--tmpfs /home/dev/.aws") { + t.Errorf("a wholly re-opened deny path must not be masked\n%s", joined) + } + if strings.Contains(joined, "--ro-bind /home/dev/.aws /home/dev/.aws") { + t.Errorf("a wholly re-opened deny path needs no re-bind (root bind already exposes it)\n%s", joined) + } + if !strings.Contains(joined, "--tmpfs /home/dev/.ssh") { + t.Errorf("other deny paths must stay masked\n%s", joined) + } +} + +// Nested denies: ~/.aws is masked and ~/.aws/config is itself a deny path that was re-opened. The +// exception still needs a re-bind, because the parent's tmpfs covers it. +func TestBwrapArgvReadExceptionUnderMaskedParent(t *testing.T) { + spec := bwrapSpec(HardFence) + spec.DenyPaths = []string{"/home/dev/.aws", "/home/dev/.aws/config"} + spec.ReadPaths = []string{"/home/dev/.aws/config"} + args := buildBwrapArgv(spec, "/proc/self/exe", []string{"claude"}, allExistingDirs) + joined := strings.Join(args, " ") + + if !strings.Contains(joined, "--tmpfs /home/dev/.aws ") && !strings.HasSuffix(joined, "--tmpfs /home/dev/.aws") { + t.Errorf("the masked parent must still be masked\n%s", joined) + } + rebind := lastArgPairIndex(args, "--ro-bind", "/home/dev/.aws/config") + mask := lastArgPairIndex(args, "--tmpfs", "/home/dev/.aws") + if rebind == -1 { + t.Fatalf("an exception under a masked parent must be re-bound\n%s", joined) + } + if rebind < mask { + t.Fatalf("the re-bind must follow the parent mask (mask=%d rebind=%d)", mask, rebind) + } +} + +func TestBwrapArgvSkipsUselessAndMissingReadExceptions(t *testing.T) { + spec := bwrapSpec(HardFence) + spec.DenyPaths = []string{"/home/dev/.aws"} + // One outside any deny (nothing to re-open), one inside a deny but missing on disk. + spec.ReadPaths = []string{"/home/dev/project/notes.md", "/home/dev/.aws/absent"} + classify := func(p string) (bool, bool) { + return p != "/home/dev/.aws/absent", false + } + joined := strings.Join(buildBwrapArgv(spec, "/proc/self/exe", []string{"claude"}, classify), " ") + + if strings.Contains(joined, "notes.md") { + t.Errorf("a path outside every deny needs no re-bind\n%s", joined) + } + // A missing source would abort bwrap at startup. + if strings.Contains(joined, "/home/dev/.aws/absent") { + t.Errorf("a missing read exception must be skipped\n%s", joined) + } +} + +func TestBwrapArgvDenyMountsWinOverCwdBind(t *testing.T) { + // Agent launched from $HOME: the cwd bind is an ancestor of every deny path. + spec := bwrapSpec(HardFence) + spec.Cwd = "/home/dev" + spec.WritePaths = []string{"/home/dev/.claude"} + spec.DenyPaths = []string{"/home/dev/.aws", "/home/dev/.ssh"} + args := buildBwrapArgv(spec, "/proc/self/exe", []string{"claude"}, allExistingDirs) + + // Every deny mount must come AFTER the cwd bind and the write bind, or the bind re-exposes it. + cwdBind := lastArgPairIndex(args, "--bind", "/home/dev") + writeBind := lastArgPairIndex(args, "--bind", "/home/dev/.claude") + for _, deny := range []string{"/home/dev/.aws", "/home/dev/.ssh"} { + denyIdx := lastArgPairIndex(args, "--tmpfs", deny) + if denyIdx == -1 { + t.Fatalf("deny path %q not masked\n%v", deny, args) + } + if denyIdx < cwdBind { + t.Errorf("deny mount %q (idx %d) must come after the cwd bind (idx %d), else the cwd bind re-exposes it", deny, denyIdx, cwdBind) + } + if denyIdx < writeBind { + t.Errorf("deny mount %q (idx %d) must come after the write bind (idx %d)", deny, denyIdx, writeBind) + } + } +} diff --git a/packages/sandbox/proc.go b/packages/sandbox/proc.go new file mode 100644 index 00000000..990eadc4 --- /dev/null +++ b/packages/sandbox/proc.go @@ -0,0 +1,58 @@ +package sandbox + +import ( + "errors" + "os" + "os/exec" + "os/signal" + "syscall" +) + +var errEmptyCommand = errors.New("sandbox: empty command") + +// newInheritedCmd returns an *exec.Cmd for full argv with the caller's stdio inherited and the given +// environment, ready to Start. +func newInheritedCmd(argv []string, env []string) *exec.Cmd { + // #nosec G204 -- the wrapped command is provided directly by the operator running the CLI + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = env + return cmd +} + +// ForwardTerminationSignals relays SIGINT/SIGTERM/SIGHUP/SIGQUIT to cmd until stop is called. +// Only these: notifying on ALL signals also delivers SIGURG (Go's async-preemption signal, fired +// constantly) and SIGCHLD, which race the child's exit path and corrupt its status into 255. +// Terminal signals like Ctrl-C reach the child directly through the shared foreground group. +func ForwardTerminationSignals(cmd *exec.Cmd) (stop func()) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) + go func() { + for sig := range sigCh { + if cmd.Process != nil { + _ = cmd.Process.Signal(sig) + } + } + }() + return func() { + signal.Stop(sigCh) + close(sigCh) + } +} + +// WaitExitCode maps Wait's error to the child's exit code. ok is false when the error is not an exit +// status at all (never started, or wait itself failed); the code is then 1 for the caller to report. +func WaitExitCode(err error) (code int, ok bool) { + if err == nil { + return 0, true + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if ws, wsOk := exitErr.Sys().(syscall.WaitStatus); wsOk { + return ws.ExitStatus(), true + } + } + return 1, false +} diff --git a/packages/sandbox/sandbox.go b/packages/sandbox/sandbox.go new file mode 100644 index 00000000..0004dc5c --- /dev/null +++ b/packages/sandbox/sandbox.go @@ -0,0 +1,39 @@ +package sandbox + +import "os/exec" + +type PreflightResult struct { + Supported bool // false => no OS sandbox here (e.g. Windows, bwrap missing) + FallbackToSharedNet bool // Linux: empty-netns hard fence unavailable, downgrade to shared net + UsesBridge bool // Linux hard fence only: proxy on a unix socket, reached via the bridge + Reason string // user-facing explanation for an unsupported result or a fallback +} + +// Backend applies an OS sandbox to a command. Wrap returns an *exec.Cmd ready to Start (stdio +// inherited, Env from the spec) but does not start it. +type Backend interface { + Preflight(spec Spec) (PreflightResult, error) + Wrap(spec Spec, argv []string) (*exec.Cmd, error) +} + +// NewBackend returns the platform backend, or the uncontained passthrough backend for --no-sandbox. +func NewBackend(spec Spec) Backend { + if !spec.Enabled { + return passthroughBackend{} + } + return osBackend() +} + +// passthroughBackend runs the command uncontained (--no-sandbox): proxy and scrubbed env still apply. +type passthroughBackend struct{} + +func (passthroughBackend) Preflight(Spec) (PreflightResult, error) { + return PreflightResult{Supported: true}, nil +} + +func (passthroughBackend) Wrap(spec Spec, argv []string) (*exec.Cmd, error) { + if len(argv) == 0 { + return nil, errEmptyCommand + } + return newInheritedCmd(argv, spec.Env), nil +} diff --git a/packages/sandbox/sandbox_unsupported.go b/packages/sandbox/sandbox_unsupported.go new file mode 100644 index 00000000..07aa8940 --- /dev/null +++ b/packages/sandbox/sandbox_unsupported.go @@ -0,0 +1,29 @@ +//go:build !darwin && !linux + +package sandbox + +import ( + "errors" + "fmt" + "os/exec" + "runtime" +) + +var errUnsupportedPlatform = errors.New("sandbox: unsupported platform") + +func osBackend() Backend { return unsupportedBackend{} } + +// unsupportedBackend is used on platforms without an OS sandbox (e.g. Windows). +type unsupportedBackend struct{} + +func (unsupportedBackend) Preflight(Spec) (PreflightResult, error) { + return PreflightResult{ + Supported: false, + Reason: fmt.Sprintf("the OS sandbox is not available on %s; re-run with --no-sandbox to run the "+ + "agent uncontained (credentials are still brokered and the environment is still scrubbed)", runtime.GOOS), + }, nil +} + +func (unsupportedBackend) Wrap(Spec, []string) (*exec.Cmd, error) { + return nil, errUnsupportedPlatform +} diff --git a/packages/sandbox/seatbelt.go b/packages/sandbox/seatbelt.go new file mode 100644 index 00000000..6093ab2c --- /dev/null +++ b/packages/sandbox/seatbelt.go @@ -0,0 +1,35 @@ +//go:build darwin + +package sandbox + +import ( + "fmt" + "os" + "os/exec" +) + +const sandboxExecPath = "/usr/bin/sandbox-exec" + +func osBackend() Backend { return seatbeltBackend{} } + +type seatbeltBackend struct{} + +func (seatbeltBackend) Preflight(Spec) (PreflightResult, error) { + if _, err := os.Stat(sandboxExecPath); err != nil { + return PreflightResult{ + Supported: false, + Reason: fmt.Sprintf("unable to find %s, so the sandbox cannot start on this host", sandboxExecPath), + }, nil + } + return PreflightResult{Supported: true}, nil +} + +// Wrap builds `sandbox-exec -p `; the profile is passed inline, never on disk. +func (seatbeltBackend) Wrap(spec Spec, argv []string) (*exec.Cmd, error) { + if len(argv) == 0 { + return nil, errEmptyCommand + } + profile := generateSeatbeltProfile(spec) + full := append([]string{sandboxExecPath, "-p", profile}, argv...) + return newInheritedCmd(full, spec.Env), nil +} diff --git a/packages/sandbox/seatbelt_live_test.go b/packages/sandbox/seatbelt_live_test.go new file mode 100644 index 00000000..568da2f5 --- /dev/null +++ b/packages/sandbox/seatbelt_live_test.go @@ -0,0 +1,222 @@ +//go:build darwin + +package sandbox + +import ( + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// These tests actually spawn sandbox-exec on the host to prove the profile enforces what we claim. +// They are gated behind INFISICAL_SANDBOX_LIVE=1 so ordinary `go test` (and CI) skip them. +func requireLive(t *testing.T) { + t.Helper() + if os.Getenv("INFISICAL_SANDBOX_LIVE") != "1" { + t.Skip("set INFISICAL_SANDBOX_LIVE=1 to run live sandbox-exec tests") + } + if _, err := os.Stat(sandboxExecPath); err != nil { + t.Skipf("%s not present", sandboxExecPath) + } +} + +func liveSpec(t *testing.T, port int) Spec { + t.Helper() + home, _ := os.UserHomeDir() + return Spec{ + Enabled: true, + Cwd: t.TempDir(), + TempDir: t.TempDir(), + LoopbackPort: port, + DenyPaths: DefaultDenyPaths(home, ""), + Env: os.Environ(), + NetMode: HardFence, + } +} + +func runInSandbox(t *testing.T, spec Spec, argv ...string) (string, error) { + t.Helper() + cmd, err := seatbeltBackend{}.Wrap(spec, argv) + if err != nil { + t.Fatal(err) + } + // Wrap sets stdio for interactive use; clear it so CombinedOutput can capture instead. + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + out, err := cmd.CombinedOutput() + return string(out), err +} + +// The credential boundary: reading a credential file must fail inside the box. +func TestLiveDeniesCredentialFileRead(t *testing.T) { + requireLive(t) + home, _ := os.UserHomeDir() + probe := home + "/.aws/credentials" + if _, err := os.Stat(probe); err != nil { + // Create a throwaway file under a denied path so the test is meaningful even without real creds. + probe = home + "/.infisical/.sandbox-probe" + _ = os.MkdirAll(home+"/.infisical", 0o700) + if werr := os.WriteFile(probe, []byte("secret"), 0o600); werr != nil { + t.Skipf("cannot stage a probe file: %v", werr) + } + defer os.Remove(probe) + } + out, err := runInSandbox(t, liveSpec(t, 51234), "/bin/cat", probe) + if err == nil { + t.Fatalf("expected reading %s to fail inside the sandbox; got output:\n%s", probe, out) + } + t.Logf("denied as expected: %v\n%s", err, out) +} + +// The central network invariant: the sandbox cannot resolve or reach anything itself, and a hostname +// only becomes reachable by being handed to the proxy. Covers the three ways out at once. +func TestLiveEgressOnlyViaProxy(t *testing.T) { + requireLive(t) + + // A stand-in proxy that answers absolute-URI requests the way the real one does. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "PROXY-SAW %s", r.Host) + })} + go func() { _ = srv.Serve(ln) }() + defer srv.Close() + + home, _ := os.UserHomeDir() + spec := liveSpec(t, port) + proxyURL := "http://127.0.0.1:" + strconv.Itoa(port) + bare := []string{"PATH=/usr/bin:/bin", "HOME=" + home} + + // 1. No resolver: a hostname can't be looked up, so a proxy-ignoring client fails closed. + spec.Env = bare + if out, err := runInSandbox(t, spec, "/usr/bin/curl", "-sS", "--max-time", "6", "http://example.com"); err == nil { + t.Errorf("a hostname must not be resolvable without the proxy, got:\n%s", out) + } + + // 2. No route: raw TCP straight to an external IP, no DNS involved, must still be refused. This is + // what proves the fence is the egress rule and not merely the absence of DNS. + if out, err := runInSandbox(t, spec, "/usr/bin/nc", "-w", "4", "-z", "1.1.1.1", "443"); err == nil { + t.Errorf("raw TCP to an external IP must be denied, got:\n%s", out) + } + + // 3. With the proxy env vars the same request succeeds, and the proxy is what saw the hostname: + // resolution happened on the host, outside the sandbox. Lowercase included on purpose (curl reads + // only lowercase http_proxy for plain-HTTP URLs). + spec.Env = append(append([]string{}, bare...), + "HTTP_PROXY="+proxyURL, "http_proxy="+proxyURL, + "HTTPS_PROXY="+proxyURL, "https_proxy="+proxyURL, + "NO_PROXY=localhost,127.0.0.1", "no_proxy=localhost,127.0.0.1") + out, err := runInSandbox(t, spec, "/usr/bin/curl", "-sS", "--max-time", "6", "http://example.com") + if err != nil { + t.Fatalf("a request through the proxy must succeed, got %v:\n%s", err, out) + } + if !strings.Contains(out, "PROXY-SAW example.com") { + t.Fatalf("the proxy should have received the hostname; got:\n%s", out) + } +} + +// The network fence: a raw outbound connection to a non-loopback host must fail. +func TestLiveDeniesExternalEgress(t *testing.T) { + requireLive(t) + // curl to a well-known host; expect a non-zero exit (connection denied by the sandbox). + out, err := runInSandbox(t, liveSpec(t, 51234), "/usr/bin/curl", "-sS", "--max-time", "5", "https://example.com") + if err == nil { + t.Fatalf("expected external egress to be denied; got:\n%s", out) + } + t.Logf("egress denied as expected: %v\n%s", err, out) +} + +// Loopback to the proxy port must be reachable: start a listener on that port and curl it. +func TestLiveAllowsLoopbackToProxy(t *testing.T) { + requireLive(t) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "reached-proxy") + })} + go func() { _ = srv.Serve(ln) }() + defer srv.Close() + + url := "http://127.0.0.1:" + strconv.Itoa(port) + "/" + out, err := runInSandbox(t, liveSpec(t, port), "/usr/bin/curl", "-sS", "--max-time", "5", url) + if err != nil { + t.Fatalf("expected loopback to the proxy port to succeed, got %v:\n%s", err, out) + } + if !strings.Contains(out, "reached-proxy") { + t.Fatalf("did not reach the loopback listener; output:\n%s", out) + } +} + +// The sandbox must not stop an ordinary command from running (baseline: profile boots a process). +func TestLiveAllowsBasicExec(t *testing.T) { + requireLive(t) + out, err := runInSandbox(t, liveSpec(t, 51234), "/bin/echo", "hello") + if err != nil { + t.Fatalf("basic exec failed inside the sandbox: %v\n%s", err, out) + } + if !strings.Contains(out, "hello") { + t.Fatalf("unexpected output: %q", out) + } +} + +// A --allow-read exception must re-open exactly one path inside a denied directory: the excepted file +// becomes readable, its siblings stay denied, and it does not become writable. +func TestLiveReadExceptionIsPreciseAndReadOnly(t *testing.T) { + requireLive(t) + home, _ := os.UserHomeDir() + denyDir := filepath.Join(home, ".infisical") + excepted := filepath.Join(denyDir, ".sandbox-probe-excepted") + sibling := filepath.Join(denyDir, ".sandbox-probe-sibling") + + if err := os.MkdirAll(denyDir, 0o700); err != nil { + t.Skipf("cannot stage the probe dir: %v", err) + } + for _, p := range []string{excepted, sibling} { + if err := os.WriteFile(p, []byte("PROBE_CONTENT\n"), 0o600); err != nil { + t.Skipf("cannot stage %s: %v", p, err) + } + defer os.Remove(p) + } + + spec := liveSpec(t, 51234) + spec.DenyPaths = []string{denyDir} + spec.ReadPaths = []string{excepted} + + if out, err := runInSandbox(t, spec, "/bin/cat", excepted); err != nil { + t.Errorf("the excepted path must be readable, got %v:\n%s", err, out) + } else if !strings.Contains(out, "PROBE_CONTENT") { + t.Errorf("unexpected content from the excepted path: %q", out) + } + + if out, err := runInSandbox(t, spec, "/bin/cat", sibling); err == nil { + t.Errorf("a sibling in the denied dir must stay denied, but it read:\n%s", out) + } + + if out, err := runInSandbox(t, spec, "/usr/bin/tee", excepted); err == nil { + t.Errorf("a read exception must not grant write, but tee succeeded:\n%s", out) + } +} + +func TestLiveKeychainReadDenied(t *testing.T) { + requireLive(t) + // `security find-generic-password` talks to the keychain mach services we omit; it must fail. + out, err := runInSandbox(t, liveSpec(t, 51234), "/usr/bin/security", "find-generic-password", "-s", "infisical") + if err == nil { + t.Fatalf("expected keychain access to be denied; got:\n%s", out) + } + t.Logf("keychain denied as expected: %v", err) +} diff --git a/packages/sandbox/seatbelt_profile.go b/packages/sandbox/seatbelt_profile.go new file mode 100644 index 00000000..0c3af73d --- /dev/null +++ b/packages/sandbox/seatbelt_profile.go @@ -0,0 +1,152 @@ +package sandbox + +import ( + "strconv" + "strings" +) + +// generateSeatbeltProfile builds the SBPL profile for a spec. Pure (no OS calls), golden-testable. +// The keychain services (securityd.xpc, SecurityServer) and trustd are deliberately omitted from the +// allow-list, so (deny default) blocks keyring reads while injected-CA TLS still works. +func generateSeatbeltProfile(spec Spec) string { + var b []string + add := func(lines ...string) { b = append(b, lines...) } + + add( + "(version 1)", + "(deny default)", + "", + "; Process", + "(allow process-exec)", + "(allow process-fork)", + "(allow process-info* (target same-sandbox))", + "(allow signal (target same-sandbox))", + "(allow mach-priv-task-port (target same-sandbox))", + "", + "(allow user-preference-read)", + "", + // DNS is omitted as deliberately as the keychain: the proxy resolves on the host, so the child + // needs no resolver. Without one, a proxy-ignoring client fails loudly instead of leaking, and + // DNS cannot be used to exfiltrate. Do not add mDNSResponder here. + "; Baseline mach services (keychain + DNS deliberately omitted; trustd gated on AllowTrustd)", + "(allow mach-lookup", + ` (global-name "com.apple.audio.systemsoundserver")`, + ` (global-name "com.apple.distributed_notifications@Uv3")`, + ` (global-name "com.apple.FontObjectsServer")`, + ` (global-name "com.apple.fonts")`, + ` (global-name "com.apple.logd")`, + ` (global-name "com.apple.lsd.mapdb")`, + ` (global-name "com.apple.system.logger")`, + ` (global-name "com.apple.system.notification_center")`, + ` (global-name "com.apple.system.opendirectoryd.libinfo")`, + ` (global-name "com.apple.system.opendirectoryd.membership")`, + ` (global-name "com.apple.bsd.dirhelper")`, + ` (global-name "com.apple.coreservices.launchservicesd")`, + ")", + "", + "; POSIX IPC", + "(allow ipc-posix-shm)", + "(allow ipc-posix-sem)", + "", + "; IOKit", + "(allow iokit-open", + ` (iokit-registry-entry-class "IOSurfaceRootUserClient")`, + ` (iokit-registry-entry-class "RootDomainUserClient")`, + ` (iokit-user-client-class "IOSurfaceSendRight")`, + ")", + "(allow iokit-get-properties)", + "", + "; System info", + "(allow sysctl-read)", + "(allow system-socket (require-all (socket-domain AF_SYSTEM) (socket-protocol 2)))", + "", + "; Device files", + `(allow file-ioctl (literal "/dev/null") (literal "/dev/zero") (literal "/dev/random") (literal "/dev/urandom") (literal "/dev/dtracehelper") (literal "/dev/tty"))`, + "", + "; Pseudo-terminal (interactive TUIs need a real pty)", + "(allow pseudo-tty)", + `(allow file-ioctl (literal "/dev/ptmx") (regex #"^/dev/ttys"))`, + `(allow file-read* file-write* (literal "/dev/ptmx") (regex #"^/dev/ttys"))`, + "", + ) + + if spec.AllowTrustd { + // Cert-trust evaluation only. securityd/SecurityServer (keychain secret reads) stay omitted, so + // the login token remains unreadable; this just lets native-trust clients verify certs. + add( + "; trustd: cert-trust evaluation (keychain secret access still denied)", + `(allow mach-lookup (global-name "com.apple.trustd") (global-name "com.apple.trustd.agent"))`, + "", + ) + } + + // Egress must be filtered on `remote ip`, not `local ip` (a local filter matches the any-address + // and would admit all egress). Bind/inbound are wildcarded so agents can run local dev servers. + add("; Network") + port := strconv.Itoa(spec.LoopbackPort) + add( + `(allow network-bind (local ip "*:*"))`, + `(allow network-inbound (local ip "*:*"))`, + `(allow network-outbound (remote ip "localhost:`+port+`"))`, + ) + add("") + + // Broad read, then subtract the credential deny paths (SBPL is last-match-wins). + add("; File read") + add("(allow file-read*)") + for _, p := range dedupeSorted(spec.DenyPaths) { + add(`(deny file-read* (subpath ` + escapeSBPL(p) + `))`) + } + add("") + + // --allow-read exceptions land after the denies but before the write section, so the trailing write + // denies still apply: an excepted path becomes readable, not writable, and its siblings stay denied. + if exceptions := dedupeSorted(spec.ReadPaths); len(exceptions) > 0 { + add("; Read exceptions (--allow-read): re-open specific paths inside the denied set") + for _, p := range exceptions { + add(`(allow file-read* (subpath ` + escapeSBPL(p) + `))`) + } + add("") + } + + add("; File write") + writePaths := dedupeSorted(append([]string{spec.Cwd, spec.TempDir, "/tmp", "/private/tmp", "/dev/null"}, spec.WritePaths...)) + for _, p := range writePaths { + if p == "" { + continue + } + if p == "/dev/null" { + add(`(allow file-write* (literal "/dev/null"))`) + continue + } + add(`(allow file-write* (subpath ` + escapeSBPL(p) + `))`) + } + add("") + + // Re-deny writes after the write allows (last-match-wins), so running the agent from $HOME cannot + // use its writable cwd to append to ~/.ssh/authorized_keys. + add("; Credential paths: deny writes too (not just reads)") + for _, p := range dedupeSorted(spec.DenyPaths) { + add(`(deny file-write* (subpath ` + escapeSBPL(p) + `))`) + } + + return strings.Join(b, "\n") + "\n" +} + +// escapeSBPL quotes a path as an SBPL (C-style) string literal. +func escapeSBPL(p string) string { + var sb strings.Builder + sb.WriteByte('"') + for _, r := range p { + switch r { + case '\\': + sb.WriteString(`\\`) + case '"': + sb.WriteString(`\"`) + default: + sb.WriteRune(r) + } + } + sb.WriteByte('"') + return sb.String() +} diff --git a/packages/sandbox/seatbelt_profile_test.go b/packages/sandbox/seatbelt_profile_test.go new file mode 100644 index 00000000..c2a982c7 --- /dev/null +++ b/packages/sandbox/seatbelt_profile_test.go @@ -0,0 +1,185 @@ +package sandbox + +import ( + "strings" + "testing" +) + +func testSpec() Spec { + return Spec{ + Enabled: true, + Cwd: "/Users/dev/project", + TempDir: "/private/tmp/infisical-run-abc", + LoopbackPort: 51234, + DenyPaths: []string{ + "/Users/dev/.aws", + "/Users/dev/.infisical", + "/Users/dev/.ssh", + }, + WritePaths: []string{"/Users/dev/.cache"}, + NetMode: HardFence, + } +} + +func TestSeatbeltProfileStructure(t *testing.T) { + p := generateSeatbeltProfile(testSpec()) + + mustContain := []string{ + "(version 1)", + "(deny default)", + "(allow pseudo-tty)", + // Egress must be filtered on remote + the exact loopback port, never local. + `(allow network-outbound (remote ip "localhost:51234"))`, + // Credential deny paths (sorted). + `(deny file-read* (subpath "/Users/dev/.aws"))`, + `(deny file-read* (subpath "/Users/dev/.infisical"))`, + `(deny file-read* (subpath "/Users/dev/.ssh"))`, + // Writable cwd + tempdir + operator path. + `(allow file-write* (subpath "/Users/dev/project"))`, + `(allow file-write* (subpath "/private/tmp/infisical-run-abc"))`, + `(allow file-write* (subpath "/Users/dev/.cache"))`, + } + for _, s := range mustContain { + if !strings.Contains(p, s) { + t.Errorf("profile missing expected line:\n %s\n---\n%s", s, p) + } + } + + mustNotContain := []string{ + // The two keychain services must be omitted so (deny default) blocks the keyring. + "com.apple.securityd.xpc", + "com.apple.SecurityServer", + // trustd omitted so the injected CA bundle is the trust path. + "com.apple.trustd", + // Never filter egress on local ip (silently admits all egress). + "(allow network-outbound (local ip", + // No blanket network allow. + "(allow network*)", + } + for _, s := range mustNotContain { + if strings.Contains(p, s) { + t.Errorf("profile must NOT contain %q\n---\n%s", s, p) + } + } +} + +func TestSeatbeltProfileTrustdToggle(t *testing.T) { + spec := testSpec() + + // Default: trustd omitted so native-trust TLS falls back to the injected CA bundle. + if strings.Contains(generateSeatbeltProfile(spec), "com.apple.trustd") { + t.Error("trustd must be omitted when AllowTrustd is false") + } + + // AllowTrustd: trustd allowed, but keychain secret services stay omitted (token stays unreadable). + spec.AllowTrustd = true + p := generateSeatbeltProfile(spec) + if !strings.Contains(p, `(global-name "com.apple.trustd")`) { + t.Errorf("expected trustd allowed when AllowTrustd is true\n%s", p) + } + for _, s := range []string{"com.apple.securityd.xpc", "com.apple.SecurityServer"} { + if strings.Contains(p, s) { + t.Errorf("keychain secret service %q must stay omitted even with AllowTrustd", s) + } + } +} + +func TestSeatbeltProfileEscaping(t *testing.T) { + spec := testSpec() + spec.DenyPaths = []string{`/Users/dev/weird "dir"\path`} + p := generateSeatbeltProfile(spec) + if !strings.Contains(p, `(subpath "/Users/dev/weird \"dir\"\\path")`) { + t.Errorf("path not escaped correctly:\n%s", p) + } +} + +func TestSeatbeltProfileDenyBeforeAllowWrite(t *testing.T) { + // A denied read path that also appears as a write path: the write allow is emitted, but the read + // deny must still be present (SBPL is last-match-wins, and this documents the ordering intent). + p := generateSeatbeltProfile(testSpec()) + readAllow := strings.Index(p, "(allow file-read*)") + firstDeny := strings.Index(p, "(deny file-read*") + if readAllow == -1 || firstDeny == -1 || firstDeny < readAllow { + t.Fatalf("expected broad read allow before the deny subtractions (allow=%d deny=%d)", readAllow, firstDeny) + } +} + +func TestSeatbeltProfileReadExceptions(t *testing.T) { + spec := testSpec() + spec.ReadPaths = []string{"/Users/dev/.aws/config"} + p := generateSeatbeltProfile(spec) + + allowLine := `(allow file-read* (subpath "/Users/dev/.aws/config"))` + if !strings.Contains(p, allowLine) { + t.Fatalf("missing the read exception:\n%s", p) + } + // SBPL is last-match-wins, so the exception must come after the deny it overrides... + denyRead := strings.Index(p, `(deny file-read* (subpath "/Users/dev/.aws"))`) + exception := strings.Index(p, allowLine) + if denyRead == -1 || exception < denyRead { + t.Fatalf("read exception must follow the read deny (deny=%d exception=%d)", denyRead, exception) + } + // ...and before the trailing write denies, so re-opening a read never grants a write. + writeDeny := strings.Index(p, `(deny file-write* (subpath "/Users/dev/.aws"))`) + if writeDeny == -1 || writeDeny < exception { + t.Fatalf("write denies must still come last (exception=%d writeDeny=%d)", exception, writeDeny) + } + if strings.Contains(p, `(allow file-write* (subpath "/Users/dev/.aws/config"))`) { + t.Error("a read exception must never emit a write allow") + } +} + +func TestSeatbeltProfileNoReadExceptionSection(t *testing.T) { + // With no --allow-read, the profile must not gain an empty exceptions section. + if strings.Contains(generateSeatbeltProfile(testSpec()), "Read exceptions") { + t.Error("no read exceptions should be emitted when ReadPaths is empty") + } +} + +func TestPassthroughBackendWrap(t *testing.T) { + spec := Spec{Enabled: false, Env: []string{"FOO=bar"}} + b := NewBackend(spec) + if _, ok := b.(passthroughBackend); !ok { + t.Fatalf("expected passthrough backend when Sandbox=false, got %T", b) + } + cmd, err := b.Wrap(spec, []string{"echo", "hi"}) + if err != nil { + t.Fatal(err) + } + if cmd.Args[0] != "echo" || cmd.Args[1] != "hi" { + t.Fatalf("unexpected argv: %v", cmd.Args) + } + if len(cmd.Env) != 1 || cmd.Env[0] != "FOO=bar" { + t.Fatalf("env not set from spec: %v", cmd.Env) + } +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +func TestDefaultDenyPaths(t *testing.T) { + got := DefaultDenyPaths("/Users/dev", "/run/user/1000") + want := map[string]bool{ + "/Users/dev/.infisical": true, + "/Users/dev/.aws": true, + "/Users/dev/.ssh": true, + } + for _, p := range got { + delete(want, p) + } + if len(want) != 0 { + t.Fatalf("DefaultDenyPaths missing entries: %v", want) + } + if want := "/run/user/1000"; !contains(got, want) { + t.Fatalf("DefaultDenyPaths must include the runtime dir %q", want) + } + if DefaultDenyPaths("", "") != nil { + t.Fatal("DefaultDenyPaths(\"\") should be nil") + } +} diff --git a/packages/sandbox/spec.go b/packages/sandbox/spec.go new file mode 100644 index 00000000..84cc136a --- /dev/null +++ b/packages/sandbox/spec.go @@ -0,0 +1,119 @@ +// Package sandbox applies an OS-level jail (macOS Seatbelt, Linux bubblewrap) around the agent +// spawned by `agent-proxy run`. The profile/argv generators are pure functions of a Spec so +// they are golden-testable on any platform; only the exec wiring is platform-specific. +package sandbox + +import ( + "sort" + "strings" +) + +type NetMode int + +const ( + // HardFence: no external egress, the proxy is the only route out (macOS (deny default) SBPL; + // Linux empty netns bridged to the proxy socket). + HardFence NetMode = iota + // SharedNet shares the host network: the Linux fallback when a network namespace is unavailable. + // Only the network fence weakens; the credential controls are unchanged. + SharedNet +) + +// Spec is the in-memory policy for one wrapped command. Nothing here is persisted. +type Spec struct { + Enabled bool // false => --no-sandbox: run uncontained + + // ReadPaths (--allow-read) re-open paths inside DenyPaths. Reads are broadly allowed by default, + // so these only matter as exceptions to the deny set, and never grant write. + ReadPaths []string + WritePaths []string + DenyPaths []string // read-denied credential paths, subtracted from the broad read allow + + Cwd string + TempDir string // per-run 0700 dir: CA cert, and the unix socket on the Linux hard fence + + // LoopbackPort is the proxy's TCP port (macOS / Linux shared-net) or the in-namespace bridge port + // (Linux hard fence); ProxySocket is the proxy's unix socket, set only on the hard fence. + LoopbackPort int + ProxySocket string + + Env []string // fully-prepared, scrubbed child environment + NetMode NetMode + + // AllowTrustd lets macOS evaluate cert trust, so Go tools like gh accept the proxy's leaves. + // securityd stays blocked either way, so keychain secrets remain unreadable. macOS only. + AllowTrustd bool +} + +// DefaultDenyPaths returns the credential paths denied by default, resolved against home. runtimeDir +// is the per-user runtime directory (/run/user/ on Linux, empty elsewhere): it holds the session +// bus and other host IPC sockets, which are not namespaced and would otherwise be reachable. +func DefaultDenyPaths(home, runtimeDir string) []string { + if home == "" { + return nil + } + rel := []string{ + ".infisical", + "infisical-keyring", // file-vault backend store (JWT + backup key); default on headless Linux + ".aws", + ".ssh", + ".config/gcloud", + ".azure", + ".kube", + ".netrc", + ".docker/config.json", + ".config/infisical", + ".config/gh", // GitHub CLI token + ".git-credentials", // git stored credentials + ".npmrc", // npm auth token + ".gnupg", // GPG private keys + } + paths := make([]string, 0, len(rel)+1) + for _, r := range rel { + paths = append(paths, home+"/"+r) + } + if runtimeDir != "" { + paths = append(paths, runtimeDir) + } + return paths +} + +func containsString(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +// underAny reports whether path is equal to, or nested inside, any of the given roots. +func underAny(path string, roots []string) bool { + for _, root := range roots { + if root == "" { + continue + } + if path == root || strings.HasPrefix(path, strings.TrimSuffix(root, "/")+"/") { + return true + } + } + return false +} + +// dedupeSorted drops empties and duplicates and sorts, so the generators emit deterministic output. +func dedupeSorted(in []string) []string { + seen := make(map[string]struct{}, len(in)) + var out []string + for _, s := range in { + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + sort.Strings(out) + return out +}