From 0beff8f69fbc159df455a0ef521bd7b57eb78520 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:21:04 +0530 Subject: [PATCH 01/13] feat(agentproxy): add local coupled mode to the proxy engine Prepare the agent proxy for single-machine, single-user use (one developer running one agent locally) without changing any existing remote behavior. - proxy.go: split Start into New/Serve/Shutdown so the caller owns the listener, serving, and teardown; Serve accepts any net.Listener (loopback :0 or a pathname unix socket); Shutdown drains in-flight requests, stops the poll/lease loops, and drops cached credential values (idempotent). Start stays a thin wrapper so the remote command is unchanged. Add Options.Local: no Proxy-Authorization (fixed startup scope), always refuse egress to the Infisical API host, and honor an --allow-host allowlist under block mode. - ca.go: add a self-signed in-memory ECDSA P-256 root that mints leaves directly and never calls out; RootPEM exposes the public cert only. - cache.go: extract the shared list-to-services resolution; the existing resolver delegates with identical semantics; add close(). - local.go: a single-snapshot resolver for the fixed scope that uses one developer token for discovery and value-fetch, gates on Read Value only, skips dynamic secrets, and fails closed on auth error. Existing tests pass unchanged; new tests cover the local CA, resolver lifecycle, auth-free local serving vs the remote challenge, the API-host block, the allow-host allowlist, and the New/Serve/Shutdown lifecycle. --- packages/agentproxy/ca.go | 67 ++++++ packages/agentproxy/cache.go | 70 ++++-- packages/agentproxy/local.go | 138 ++++++++++++ packages/agentproxy/local_test.go | 345 ++++++++++++++++++++++++++++++ packages/agentproxy/proxy.go | 170 ++++++++++++--- 5 files changed, 746 insertions(+), 44 deletions(-) create mode 100644 packages/agentproxy/local.go create mode 100644 packages/agentproxy/local_test.go diff --git a/packages/agentproxy/ca.go b/packages/agentproxy/ca.go index 6c56502a..cfb4e9a9 100644 --- a/packages/agentproxy/ca.go +++ b/packages/agentproxy/ca.go @@ -27,11 +27,20 @@ const ( leafTTL = 24 * time.Hour leafReuseMargin = 1 * time.Hour maxLeafCacheEntries = 8192 + + // localRootTTL bounds the self-signed local root used by `agent-proxy run`. The root lives only in + // memory for the lifetime of one wrapped command; the TTL just needs to comfortably outlast any + // single agent session. + localRootTTL = 7 * 24 * time.Hour ) type caManager struct { token func() string + // local marks the self-signed local-root mode used by `agent-proxy run`: the "intermediate" fields + // hold a self-signed root minted at construction, never re-signed via Infisical, never persisted. + local bool + mu sync.Mutex intermediateKey *ecdsa.PrivateKey intermediateCert *x509.Certificate @@ -55,7 +64,65 @@ func newCaManager(token func() string) *caManager { } } +// newLocalCaManager builds the CA for local coupled mode: a self-signed ECDSA P-256 root generated in +// memory, from which mintLeaf signs per-host leaves directly. P-256 is deliberate (rustls-based agents +// reject exotic curves). The private key never leaves process memory; only RootPEM (public) may be +// written out for the child's trust bundle. +func newLocalCaManager() (*caManager, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return 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, 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, fmt.Errorf("failed to self-sign local root CA: %w", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("failed to parse local root CA certificate: %w", err) + } + return &caManager{ + local: true, + intermediateKey: key, + intermediateCert: cert, + intermediateExp: cert.NotAfter, + leafCache: make(map[string]*leafEntry), + }, nil +} + +// RootPEM returns the public certificate of the local root (nil outside local mode). Public only: +// safe to write to the per-run tempdir for SSL_CERT_FILE / NODE_EXTRA_CA_CERTS. +func (c *caManager) RootPEM() []byte { + if !c.local { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + if c.intermediateCert == nil { + return nil + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.intermediateCert.Raw}) +} + func (c *caManager) ensureIntermediate() 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() diff --git a/packages/agentproxy/cache.go b/packages/agentproxy/cache.go index 39146cb5..35f1d6f1 100644 --- a/packages/agentproxy/cache.go +++ b/packages/agentproxy/cache.go @@ -202,8 +202,40 @@ 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 parameterizes the pieces of service resolution that differ between the two resolver +// implementations: remote (agentCache: discovery with the agent's wire JWT, values with the proxy MI, +// Proxy permission required, dynamic secrets leased) and local (localResolver: one developer token for +// both, Read Value is the only gate, dynamic secrets skipped). +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 turns the proxied-services list for a scope into resolvedServices with credential +// values attached. Shared by both resolver implementations; behavior 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 +244,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 +259,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 +270,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 +299,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 +320,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 +335,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/local.go b/packages/agentproxy/local.go new file mode 100644 index 00000000..1379e95a --- /dev/null +++ b/packages/agentproxy/local.go @@ -0,0 +1,138 @@ +package agentproxy + +import ( + "sync" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/rs/zerolog/log" +) + +// LocalOptions switches the proxy into local coupled mode (`agent-proxy run`): one developer, one +// sandboxed agent, one scope, for the lifetime of one wrapped command. In this mode the proxy accepts +// requests without Proxy-Authorization (the wire carries no credential at all; the child env must +// never hold a token), serves every request under the scope fixed here, and performs all Infisical +// API calls with the developer's own token. +type LocalOptions struct { + ProjectID string + Environment string + SecretPath string + + // UserToken returns the developer's current access token (keyring login or INFISICAL_TOKEN). + // It is called per API request so a refresher can rotate the token behind it. + UserToken func() string + + // InfisicalHost is the hostname (no scheme/port) of the Infisical API. Egress to it through the + // proxy is always refused: the control plane must stay unreachable from the sandbox as an + // invariant, not merely because the child holds no token. + InfisicalHost string + + // IdentityID and IdentityName label activity records; there is 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 local-mode counterpart of agentCache: a single snapshot of resolved services +// for the fixed startup scope instead of a keyed multi-agent cache. There is no entry map, eviction, +// or TTL bookkeeping because the one caller is known before the proxy starts. The snapshot refreshes +// on the poll loop and is dropped (fail closed) when the developer's authorization goes away. +type localResolver struct { + opts *LocalOptions + + mu sync.Mutex + services []*resolvedService + valid bool + + dynamicWarned bool +} + +func newLocalResolver(opts *LocalOptions) *localResolver { + return &localResolver{opts: opts} +} + +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 (the identity collapse), and no CanProxy filter: + // locally the gate is Read Value alone, so a visible service whose secrets the developer can read + // is brokered even without the Proxy permission. + token := l.opts.UserToken() + return resolveServices(l.opts.scope(), resolveParams{ + discoveryToken: token, + valueToken: func() string { return token }, + includeNonProxyable: true, + registerDynamic: func(cred api.ProxiedServiceCredential, _ string) *dynamicCredentialRef { + l.mu.Lock() + warned := l.dynamicWarned + l.dynamicWarned = true + l.mu.Unlock() + if !warned { + log.Warn().Msgf("proxied service references dynamic secret %q; dynamic secrets are not supported in local mode, skipping those credentials", cred.DynamicSecretName) + } + return nil + }, + }) +} + +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 exists for the lease refresh loop; local mode never registers leases. +func (l *localResolver) activeJWTs() map[string]struct{} { + return map[string]struct{}{} +} + +// close drops the snapshot so credential values are unreachable; the next request must re-resolve +// (and fails if the developer's authorization is gone). Also the fail-closed path for refreshActive. +func (l *localResolver) close() { + l.mu.Lock() + defer l.mu.Unlock() + l.services = nil + l.valid = false +} diff --git a/packages/agentproxy/local_test.go b/packages/agentproxy/local_test.go new file mode 100644 index 00000000..af3a8e73 --- /dev/null +++ b/packages/agentproxy/local_test.go @@ -0,0 +1,345 @@ +package agentproxy + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "encoding/pem" + "fmt" + "net" + "net/http" + "testing" + "time" +) + +// newLocalTestProxy builds a local-mode proxyServer with an injected snapshot, mirroring +// newTestProxy's pipe/one-shot-listener harness for the remote tests. +func newLocalTestProxy(t *testing.T, local *LocalOptions, services []*resolvedService, rt http.RoundTripper) net.Conn { + t.Helper() + resolver := newLocalResolver(local) + resolver.services = services + resolver.valid = true + ps := &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedAllow, Local: local}, + cache: resolver, + transport: rt, + } + 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 +} + +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) + + // 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) + } + + if len(r.activeJWTs()) != 0 { + t.Fatal("local mode must register no lease JWTs") + } + + r.close() + if r.valid || r.services != nil { + t.Fatal("close must drop the snapshot (fail closed)") + } +} + +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 "" })) + ps := &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedAllow}, + cache: cache, + transport: &http.Transport{}, + } + 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() }) + _ = 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 TestLocalModeBlocksInfisicalHost(t *testing.T) { + local := &LocalOptions{ + ProjectID: "proj", Environment: "prod", SecretPath: "/", + UserToken: func() string { return "dev-token" }, + InfisicalHost: "app.infisical.com", + } + client := newLocalTestProxy(t, local, nil, &stubRoundTripper{respBody: "ok"}) + _ = client.SetDeadline(time.Now().Add(10 * time.Second)) + + // Case-insensitive hostname match; blocked even though unmatched hosts default to allow. + if _, err := fmt.Fprintf(client, "GET http://App.Infisical.Com/api/v1/secrets HTTP/1.1\r\nHost: app.infisical.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.StatusForbidden { + t.Fatalf("expected 403 for the Infisical host, got %d", resp.StatusCode) + } +} + +func TestUnmatchedBlockRespectsAllowHost(t *testing.T) { + local := &LocalOptions{ + ProjectID: "proj", Environment: "prod", SecretPath: "/", + UserToken: func() string { return "dev-token" }, + } + resolver := newLocalResolver(local) + resolver.services = nil // no proxied services: every host is "unmatched" + resolver.valid = true + stub := &stubRoundTripper{respBody: "ok"} + ps := &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedBlock, Local: local, AllowedHosts: []string{"docs.internal"}}, + cache: resolver, + transport: stub, + } + 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() }) + _ = 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) + resolver.valid = true + ps := &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedBlock, Local: local, AllowedHosts: []string{"docs.internal"}}, + cache: resolver, + transport: &stubRoundTripper{respBody: "ok"}, + } + 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() }) + _ = 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 617e412d..18eee04e 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -76,25 +76,62 @@ type Options struct { UnmatchedHost string PollInterval time.Duration ProxyToken func() string + + // Local switches the proxy into local coupled mode (`agent-proxy run`): no Proxy-Authorization on + // the wire, one fixed scope, self-signed local root CA, developer-token resolution. Nil = remote. + Local *LocalOptions + + // AllowedHosts are extra hostnames that pass through (no credential injected) even when + // UnmatchedHost is block. Used by `run --allow-host`; empty otherwise. + AllowedHosts []string +} + +// serviceResolver is the proxy's view of credential resolution. Two implementations: agentCache +// (remote: many agents keyed by wire JWT+scope) and localResolver (local coupled mode: one snapshot +// for one known user). close drops all cached credential values at shutdown. +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 + cache serviceResolver leases *leaseStore transport http.RoundTripper } -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 cache serviceResolver + if opts.Local != nil { + localCa, err := newLocalCaManager() + if err != nil { + return nil, err + } + ca = localCa + cache = newLocalResolver(opts.Local) + } else { + ca = newCaManager(opts.ProxyToken) + cache = newAgentCache(opts.ProxyToken, leases) + } + return &proxyServer{ opts: opts, - ca: newCaManager(opts.ProxyToken), - cache: newAgentCache(opts.ProxyToken, leases), + ca: ca, + cache: cache, leases: leases, transport: newUpstreamTransport(), - } + }, nil } // Forces HTTP/1.1: h2 responses have no HTTP/1.1 length framing and would hang the re-serialized MITM tunnel; a non-nil empty TLSNextProto is what actually disables h2. @@ -121,17 +158,70 @@ func (ps *proxyServer) newFrontServer() *http.Server { } } -func Start(opts Options) error { - ps := newProxyServer(opts) +// Proxy is a lifecycle-controllable agent proxy instance: the caller owns binding (any net.Listener, +// including loopback :0 and pathname unix sockets), serving, and shutdown. `agent-proxy run` drives +// this directly; the remote `start` command keeps using the blocking Start wrapper below. +type Proxy struct { + ps *proxyServer + srv *http.Server + loopStop chan struct{} + stopOnce sync.Once +} +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.ensureIntermediate(); err != nil { - return fmt.Errorf("failed to initialize agent proxy CA: %w", err) + return nil, fmt.Errorf("failed to initialize agent proxy CA: %w", err) + } + return &Proxy{ + ps: ps, + srv: ps.newFrontServer(), + loopStop: make(chan struct{}), + }, nil +} + +// Serve starts the poll and lease loops and serves on ln until Shutdown (returns nil) or a serve +// error. The listener is owned by the caller until passed here, then closed by the server. +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.cache.activeJWTs) + + err := p.srv.Serve(newLimitListener(ln, maxConcurrentConns)) + if errors.Is(err, http.ErrServerClosed) { + return nil } + return err +} - go ps.pollLoop() +// Shutdown stops the loops, revokes active leases, drains in-flight requests (bounded by ctx), and +// drops all cached credential values. Safe to call more than once. +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) + p.ps.cache.close() + }) + return err +} + +// LocalRootPEM returns the public certificate of the local self-signed root CA (local mode only; +// nil otherwise). This is what the run command writes to the tempdir for the child's trust bundle. +func (p *Proxy) LocalRootPEM() []byte { + return p.ps.ca.RootPEM() +} - leaseStop := make(chan struct{}) - go ps.leases.refreshLoop(leaseStop, opts.PollInterval, ps.cache.activeJWTs) +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) @@ -144,25 +234,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 { @@ -176,16 +258,32 @@ 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() + for { + select { + case <-ticker.C: + ps.cache.refreshActive() + case <-stop: + return + } + } +} + +// requestScope resolves the scope and wire credential for a request. Remote mode requires +// Proxy-Authorization (a shared proxy must know which agent and scope each request belongs to). +// Local mode serves one known user under the scope fixed at startup, so no wire credential exists; +// any Proxy-Authorization header the client happens to send is ignored. +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) { @@ -198,7 +296,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 @@ -293,7 +391,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 @@ -441,6 +539,13 @@ type forwardOutcome struct { } func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt string, scope agentScope) (*http.Response, forwardOutcome, error) { + // Local mode invariant: the control plane is never reachable from the sandboxed agent, even under + // --unmatched-host allow. The child holds no token so such calls could only fail anyway; blocking + // makes the failure legible and keeps the guarantee independent of the env staying tokenless. + if l := ps.opts.Local; l != nil && l.InfisicalHost != "" && strings.EqualFold(hostname, l.InfisicalHost) { + return nil, forwardOutcome{}, fmt.Errorf("host %q is the Infisical API and is not reachable from the sandboxed agent: %w", hostname, errHostBlocked) + } + services, err := ps.cache.get(jwt, scope) if err != nil { return nil, forwardOutcome{}, fmt.Errorf("failed to resolve agent permissions: %w", err) @@ -452,7 +557,7 @@ func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt st 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) } @@ -483,6 +588,17 @@ func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt st return resp, outcome, nil } +// hostAllowlisted reports whether hostname is in the operator's --allow-host set (case-insensitive). +// These pass through under UnmatchedBlock with no credential injected. +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 { From 6e236f846a5bbe60050af3127fcb52f64579d83e Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:21:19 +0530 Subject: [PATCH 02/13] feat(sandbox): add OS sandbox (macOS Seatbelt, Linux bubblewrap) The trust boundary for local mode: it keeps the untrusted agent from reading the developer's real credentials even though they live on the same machine. - spec.go / sandbox.go: the SandboxSpec policy, the Backend interface, platform selection, and an uncontained passthrough backend for --no-sandbox. - seatbelt.go / seatbelt_profile.go (macOS): generate an SBPL profile and exec sandbox-exec. (deny default), loopback-only egress to the proxy port, broad read minus credential deny paths, and the keychain services omitted so the agent cannot read the login token; profile passed inline, never written to disk. - bwrap.go / bwrap_args.go / bridge.go (Linux): build a bubblewrap argv. Default hard fence is an empty network namespace with a small in-namespace bridge (loopback TCP -> the proxy's pathname unix socket); auto-falls back to shared host networking with a warning when unprivileged user namespaces are restricted. No --new-session, so the interactive TTY is preserved. - sandbox_unsupported.go: platforms without a sandbox report unsupported. Golden tests cover the SBPL profile and both bwrap argv shapes; an env-gated live test exercises sandbox-exec enforcement on macOS. --- packages/sandbox/bridge.go | 136 +++++++++++++++++++ packages/sandbox/bwrap.go | 89 +++++++++++++ packages/sandbox/bwrap_args.go | 97 ++++++++++++++ packages/sandbox/bwrap_args_test.go | 106 +++++++++++++++ packages/sandbox/sandbox.go | 67 ++++++++++ packages/sandbox/sandbox_unsupported.go | 23 ++++ packages/sandbox/seatbelt.go | 43 ++++++ packages/sandbox/seatbelt_live_test.go | 136 +++++++++++++++++++ packages/sandbox/seatbelt_profile.go | 154 ++++++++++++++++++++++ packages/sandbox/seatbelt_profile_test.go | 120 +++++++++++++++++ packages/sandbox/spec.go | 88 +++++++++++++ 11 files changed, 1059 insertions(+) create mode 100644 packages/sandbox/bridge.go create mode 100644 packages/sandbox/bwrap.go create mode 100644 packages/sandbox/bwrap_args.go create mode 100644 packages/sandbox/bwrap_args_test.go create mode 100644 packages/sandbox/sandbox.go create mode 100644 packages/sandbox/sandbox_unsupported.go create mode 100644 packages/sandbox/seatbelt.go create mode 100644 packages/sandbox/seatbelt_live_test.go create mode 100644 packages/sandbox/seatbelt_profile.go create mode 100644 packages/sandbox/seatbelt_profile_test.go create mode 100644 packages/sandbox/spec.go diff --git a/packages/sandbox/bridge.go b/packages/sandbox/bridge.go new file mode 100644 index 00000000..10f36db9 --- /dev/null +++ b/packages/sandbox/bridge.go @@ -0,0 +1,136 @@ +//go:build linux + +package sandbox + +import ( + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "syscall" + + "golang.org/x/sys/unix" +) + +// RunSupervisor is the entry point for the hidden `__sandbox-supervisor` subcommand. It runs INSIDE +// the bwrap network namespace (re-exec'd by the bwrap argv on the hard-fence path). It brings loopback +// up, starts a TCP->unix bridge so the child's HTTP(S)_PROXY (127.0.0.1:port) reaches the parent's +// proxy unix socket, then execs the agent as its child and forwards signals / propagates exit code. +// +// probe=true is the capability check used by Preflight: bring loopback up and return the result +// without starting a bridge or agent. +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 bind: if we cannot own the loopback proxy port, abort rather than let the agent's + // traffic flow to whatever is already there (a same-namespace MITM risk). + 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) + + // #nosec G204 -- the wrapped command is provided directly by the operator running the CLI + agent := exec.Command(argv[0], argv[1:]...) + agent.Stdin = os.Stdin + agent.Stdout = os.Stdout + agent.Stderr = os.Stderr + agent.Env = os.Environ() + + if err := agent.Start(); err != nil { + fmt.Fprintf(os.Stderr, "sandbox supervisor: failed to start the agent: %v\n", err) + return 1 + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh) + go func() { + for sig := range sigCh { + if agent.Process != nil { + _ = agent.Process.Signal(sig) + } + } + }() + + err = agent.Wait() + signal.Stop(sigCh) + if err == nil { + return 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok { + return ws.ExitStatus() + } + } + fmt.Fprintf(os.Stderr, "sandbox supervisor: agent error: %v\n", err) + return 1 +} + +// runBridge accepts loopback TCP connections and forwards each to the parent's proxy unix socket, +// copying bytes both directions. The socket must be a pathname socket (bind-mounted in); abstract +// sockets are network-namespace-scoped and would be 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() + + done := make(chan struct{}, 2) + go func() { _, _ = io.Copy(upstream, client); done <- struct{}{} }() + go func() { _, _ = io.Copy(client, upstream); done <- struct{}{} }() + <-done +} + +// bringLoopbackUp sets the loopback interface UP inside the current network namespace. An empty netns +// starts with lo DOWN, so nothing (not even 127.0.0.1) works until this runs. +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 + } + // Set IFF_UP | IFF_RUNNING on the interface flags. + flags := ifr.Uint16() | unix.IFF_UP | unix.IFF_RUNNING + ifr.SetUint16(flags) + return unix.IoctlIfreq(fd, unix.SIOCSIFFLAGS, ifr) +} diff --git a/packages/sandbox/bwrap.go b/packages/sandbox/bwrap.go new file mode 100644 index 00000000..92c6cf6c --- /dev/null +++ b/packages/sandbox/bwrap.go @@ -0,0 +1,89 @@ +//go:build linux + +package sandbox + +import ( + "fmt" + "os" + "os/exec" +) + +func osBackend() Backend { return bwrapBackend{} } + +type bwrapBackend struct{} + +// Preflight checks bwrap is installed and decides hard fence vs shared-net fallback. The decision is +// made by actually attempting an empty-netns bwrap invocation (probe-by-execution), which is more +// reliable across distros than parsing sysctls: on Ubuntu 24.04+ the AppArmor +// kernel.apparmor_restrict_unprivileged_userns restriction makes the probe fail, and we fall back. +func (bwrapBackend) Preflight(spec SandboxSpec) (PreflightResult, error) { + bwrapPath, err := exec.LookPath("bwrap") + if err != nil { + return PreflightResult{ + Supported: false, + Reason: "bubblewrap (bwrap) is not installed; install it (e.g. `apt install bubblewrap`) 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 + } + + // Empty-netns hard fence unavailable (most commonly the Ubuntu 24.04 userns restriction). Fall back + // to shared host networking. Credential controls are unaffected; only the network fence weakens. + return PreflightResult{ + Supported: true, + FallbackToSharedNet: true, + UsesBridge: false, + Reason: "unprivileged user namespaces appear restricted (e.g. Ubuntu 24.04 AppArmor). To restore the hard fence, install an AppArmor profile allowing bwrap userns or set kernel.apparmor_restrict_unprivileged_userns=0", + }, nil +} + +// hardFenceWorks probes whether an empty-netns bwrap can start and bring loopback up, by running the +// binary in the supervisor's probe mode inside the sandbox. +func hardFenceWorks(bwrapPath string) bool { + self, err := os.Executable() + if err != nil { + return false + } + // #nosec G204 -- fixed argv, no user input + cmd := exec.Command(bwrapPath, + "--unshare-all", "--die-with-parent", + "--ro-bind", "/", "/", + "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", + "--", self, SupervisorSubcommand, "--probe", + ) + return cmd.Run() == nil +} + +// Wrap builds the bwrap argv and returns an exec.Cmd. Stdio is inherited so the interactive TUI works; +// on the hard-fence path the child is the supervisor (which execs the agent), on shared net it is the +// agent directly. +func (bwrapBackend) Wrap(spec SandboxSpec, argv []string) (*exec.Cmd, error) { + if len(argv) == 0 { + return nil, fmt.Errorf("sandbox: empty command") + } + 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) + // full[0] is the literal "bwrap"; exec with the resolved path but keep argv[0] as bwrap. + // #nosec G204 -- the wrapped command is provided directly by the operator running the CLI + cmd := exec.Command(bwrapPath, full[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = spec.Env + return cmd, nil +} diff --git a/packages/sandbox/bwrap_args.go b/packages/sandbox/bwrap_args.go new file mode 100644 index 00000000..57fcf73b --- /dev/null +++ b/packages/sandbox/bwrap_args.go @@ -0,0 +1,97 @@ +package sandbox + +// BridgeLoopbackPort is the loopback port the in-namespace bridge listens on (hard-fence path). The +// child's HTTP(S)_PROXY targets 127.0.0.1:this. Because each hard-fence run gets its own empty network +// namespace, this port never collides across concurrent runs, so a fixed value is safe. +const BridgeLoopbackPort = 17321 + +// SupervisorSubcommand is the hidden argv token that re-enters this binary as the in-namespace +// supervisor (brings up loopback, starts the TCP->unix bridge, then execs the agent). Exported so the +// cmd package registers a matching hidden cobra command. +const SupervisorSubcommand = "__sandbox-supervisor" + +// buildBwrapArgv builds the full argv (starting with "bwrap") for a spec. Pure function, no OS calls, +// so it is golden-testable on any platform. +// +// - selfExe is the path to this binary (/proc/self/exe at call time), re-executed as the supervisor +// on the hard-fence path. +// - argv is the agent command. +// +// Hard fence (NetMode == HardFence): --unshare-all gives an empty network namespace; the proxy's unix +// socket (spec.ProxySocket) is bind-mounted in and the supervisor bridges LoopbackPort -> that socket. +// Shared net (NetMode == SharedNet): --unshare-all --share-net keeps host networking, so the child +// reaches the proxy on host loopback directly and no supervisor/bridge is inserted. +// +// Deliberately omits --new-session (it calls setsid() and breaks the interactive TTY / job control). +func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string) []string { + args := []string{ + "bwrap", + "--unshare-all", + "--die-with-parent", + // A read-only view of the whole filesystem keeps arbitrary toolchains working; credential paths + // are masked back out below, and the workspace/tempdir are bound writable on top. + "--ro-bind", "/", "/", + "--dev", "/dev", + "--proc", "/proc", + "--tmpfs", "/tmp", + } + + if spec.NetMode == SharedNet { + args = append(args, "--share-net") + } + + // Mask credential paths: an empty tmpfs on top of each hides the real contents (they were visible + // via --ro-bind / /). tmpfs creates the mountpoint, so a non-existent path is harmless. + for _, p := range dedupeSorted(spec.DenyPaths) { + args = append(args, "--tmpfs", p) + } + + // Writable workspace + tempdir, bound AFTER --tmpfs /tmp so a tempdir under /tmp is not shadowed. + 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) + } + + args = append(args, "--") + + if spec.NetMode == HardFence && spec.ProxySocket != "" { + // Re-exec ourselves as the in-namespace supervisor: it brings loopback up, bridges the child's + // loopback proxy port to the bound unix socket, then execs the agent. + args = append(args, + selfExe, SupervisorSubcommand, + "--port", itoa(spec.LoopbackPort), + "--socket", spec.ProxySocket, + "--", + ) + } + + return append(args, argv...) +} + +// itoa avoids pulling strconv into the argv builder's tiny surface (keeps it trivially pure). +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/packages/sandbox/bwrap_args_test.go b/packages/sandbox/bwrap_args_test.go new file mode 100644 index 00000000..8ce16361 --- /dev/null +++ b/packages/sandbox/bwrap_args_test.go @@ -0,0 +1,106 @@ +package sandbox + +import ( + "strings" + "testing" +) + +func bwrapSpec(net NetMode) SandboxSpec { + return SandboxSpec{ + Sandbox: 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, + } +} + +// argIndex returns the index of the first occurrence of tok, or -1. +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"}) + joined := strings.Join(args, " ") + + for _, want := range []string{ + "--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") + } + if strings.Contains(joined, "--new-session") { + t.Error("must never pass --new-session (breaks the interactive TTY)") + } + + // 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"}) + 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 TestItoa(t *testing.T) { + cases := map[int]string{0: "0", 7: "7", 17321: "17321", -42: "-42"} + for in, want := range cases { + if got := itoa(in); got != want { + t.Errorf("itoa(%d) = %q, want %q", in, got, want) + } + } +} diff --git a/packages/sandbox/sandbox.go b/packages/sandbox/sandbox.go new file mode 100644 index 00000000..f8282546 --- /dev/null +++ b/packages/sandbox/sandbox.go @@ -0,0 +1,67 @@ +package sandbox + +import ( + "errors" + "fmt" + "os" + "os/exec" +) + +var errUnsupportedPlatform = errors.New("sandbox: unsupported platform") + +// PreflightResult reports whether the OS sandbox can run here and, on Linux, whether the hard fence +// must fall back to shared networking. +type PreflightResult struct { + // Supported is false when no OS sandbox is available (e.g. Windows, or bwrap missing). The caller + // then either errors or, if the user passed --no-sandbox, runs uncontained. + Supported bool + // FallbackToSharedNet is set on Linux when the empty-netns hard fence cannot start (restricted + // unprivileged user namespaces). The caller downgrades NetMode to SharedNet and warns once. + FallbackToSharedNet bool + // UsesBridge is true only on the Linux hard-fence path: the child runs in an empty network + // namespace and reaches the proxy through the in-namespace bridge, so the proxy must listen on a + // pathname unix socket (not TCP) and the child's HTTP(S)_PROXY targets the bridge's loopback port. + // macOS, the Linux shared-net fallback, and --no-sandbox all leave this false (TCP loopback proxy). + UsesBridge bool + // Reason explains an unsupported result or a fallback, suitable for a user-facing message. + Reason string +} + +// Backend applies an OS sandbox to a command. +type Backend interface { + // Preflight reports whether this backend can run the given spec here, and any required fallback. + Preflight(spec SandboxSpec) (PreflightResult, error) + // Wrap returns an *exec.Cmd ready to Start: the target command wrapped by the OS sandbox, with + // stdio inherited and Env set from the spec. It does not Start the process. + Wrap(spec SandboxSpec, argv []string) (*exec.Cmd, error) +} + +// NewBackend returns the OS sandbox backend for the current platform. When spec.Sandbox is false it +// returns the passthrough backend regardless of platform. +func NewBackend(spec SandboxSpec) Backend { + if !spec.Sandbox { + return passthroughBackend{} + } + return osBackend() +} + +// passthroughBackend runs the command uncontained (the --no-sandbox path). The ephemeral proxy and +// the scrubbed env still apply; only the OS controls are dropped. +type passthroughBackend struct{} + +func (passthroughBackend) Preflight(SandboxSpec) (PreflightResult, error) { + return PreflightResult{Supported: true}, nil +} + +func (passthroughBackend) Wrap(spec SandboxSpec, argv []string) (*exec.Cmd, error) { + if len(argv) == 0 { + return nil, fmt.Errorf("sandbox: empty command") + } + // #nosec G204 -- the 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 = spec.Env + return cmd, nil +} diff --git a/packages/sandbox/sandbox_unsupported.go b/packages/sandbox/sandbox_unsupported.go new file mode 100644 index 00000000..8e93f7ce --- /dev/null +++ b/packages/sandbox/sandbox_unsupported.go @@ -0,0 +1,23 @@ +//go:build !darwin && !linux + +package sandbox + +import "os/exec" + +func osBackend() Backend { return unsupportedBackend{} } + +// unsupportedBackend is the OS sandbox on platforms without one (e.g. Windows). Preflight reports +// unsupported so the run command errors unless the user passed --no-sandbox (which selects the +// passthrough backend instead and never reaches here). +type unsupportedBackend struct{} + +func (unsupportedBackend) Preflight(SandboxSpec) (PreflightResult, error) { + return PreflightResult{ + Supported: false, + Reason: "the OS sandbox is not available on this platform (macOS and Linux only in v1); re-run with --no-sandbox to run uncontained", + }, nil +} + +func (unsupportedBackend) Wrap(SandboxSpec, []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..184a5c05 --- /dev/null +++ b/packages/sandbox/seatbelt.go @@ -0,0 +1,43 @@ +//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(SandboxSpec) (PreflightResult, error) { + if _, err := os.Stat(sandboxExecPath); err != nil { + return PreflightResult{ + Supported: false, + Reason: fmt.Sprintf("%s not found; cannot sandbox on this macOS host", sandboxExecPath), + }, nil + } + return PreflightResult{Supported: true}, nil +} + +// Wrap builds: sandbox-exec -p . sandbox-exec takes the command directly (no -- +// separator). The profile is passed inline via -p, never written to disk. Stdio is inherited so the +// interactive TUI works. +func (seatbeltBackend) Wrap(spec SandboxSpec, argv []string) (*exec.Cmd, error) { + if len(argv) == 0 { + return nil, fmt.Errorf("sandbox: empty command") + } + profile := generateSeatbeltProfile(spec) + args := append([]string{"-p", profile}, argv...) + // #nosec G204 -- the command is provided directly by the operator running the CLI + cmd := exec.Command(sandboxExecPath, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = spec.Env + return cmd, nil +} diff --git a/packages/sandbox/seatbelt_live_test.go b/packages/sandbox/seatbelt_live_test.go new file mode 100644 index 00000000..25cc5b4d --- /dev/null +++ b/packages/sandbox/seatbelt_live_test.go @@ -0,0 +1,136 @@ +//go:build darwin + +package sandbox + +import ( + "fmt" + "net" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + "testing" + "time" +) + +// 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) SandboxSpec { + t.Helper() + home, _ := os.UserHomeDir() + return SandboxSpec{ + Sandbox: true, + Cwd: t.TempDir(), + TempDir: t.TempDir(), + LoopbackPort: port, + DenyPaths: DefaultDenyPaths(home), + Env: os.Environ(), + NetMode: HardFence, + } +} + +func runInSandbox(t *testing.T, spec SandboxSpec, 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 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) + } + _ = exec.Command // keep import if trimmed +} + +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) + _ = time.Second +} diff --git a/packages/sandbox/seatbelt_profile.go b/packages/sandbox/seatbelt_profile.go new file mode 100644 index 00000000..62bf435e --- /dev/null +++ b/packages/sandbox/seatbelt_profile.go @@ -0,0 +1,154 @@ +package sandbox + +import ( + "sort" + "strconv" + "strings" +) + +// generateSeatbeltProfile builds the SBPL (Seatbelt profile language) string for a spec. It is a pure +// function (no OS calls) so it can be golden-tested on any platform. +// +// The profile grants the minimal baseline a process needs to run, with two deliberate deltas for our +// credential boundary: +// +// 1. The keychain mach services (com.apple.securityd.xpc, com.apple.SecurityServer) are OMITTED from +// the allow-list, so (deny default) blocks them and the child cannot read the developer's login +// token from the keyring. We block by omission, never by an explicit deny against a broad allow. +// 2. trustd.agent is likewise omitted; system-root TLS relies on the injected CA bundle instead. +// This is what makes the keychain block TLS-safe: keychain and TLS trust are separate services. +// +// Egress is filtered on (remote ip ...), never (local ip ...): a local filter is evaluated against +// the source address, which for an unbound socket is the any-address, so it would silently admit all +// egress. +func generateSeatbeltProfile(spec SandboxSpec) 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)", + "", + "; Baseline mach services a process needs to boot, minus the keychain services so the login", + "; token is unreadable, and minus trustd so system-root TLS falls back to the injected CA bundle.", + "(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"))`, + "", + ) + + // Network. (deny default) already blocks external egress; we allow loopback to the proxy port and + // local binding (so agents can run dev servers). Outbound stays pinned to the proxy port only. + 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("") + + // File read: broad allow, then subtract the credential deny paths (last-match-wins in SBPL). + add("; File read") + add("(allow file-read*)") + for _, p := range dedupeSorted(spec.DenyPaths) { + add(`(deny file-read* (subpath ` + escapeSBPL(p) + `))`) + } + add("") + + // File write: cwd, tmp, tempdir, and any operator-granted write paths. + 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) + `))`) + } + + return strings.Join(b, "\n") + "\n" +} + +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 +} + +// escapeSBPL quotes a path as an SBPL string literal. SBPL uses C-style double-quoted strings, so +// backslash and double-quote must be escaped. +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..79a4e662 --- /dev/null +++ b/packages/sandbox/seatbelt_profile_test.go @@ -0,0 +1,120 @@ +package sandbox + +import ( + "strings" + "testing" +) + +func testSpec() SandboxSpec { + return SandboxSpec{ + Sandbox: 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 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 TestPassthroughBackendWrap(t *testing.T) { + spec := SandboxSpec{Sandbox: 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 TestDefaultDenyPaths(t *testing.T) { + got := DefaultDenyPaths("/Users/dev") + 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 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..57651c10 --- /dev/null +++ b/packages/sandbox/spec.go @@ -0,0 +1,88 @@ +// Package sandbox builds and applies an OS-level jail around the agent process spawned by +// `infisical secrets agent-proxy run`. It is the trust boundary for local coupled mode: the +// developer's real credentials exist on the same machine, so the sandbox is what stops the +// untrusted agent from reading them. macOS uses Seatbelt (sandbox-exec); Linux uses bubblewrap. +// +// The profile/argv generators are pure functions of a SandboxSpec (no OS calls), so they are +// unit-testable with golden files on any platform. Only the exec wiring is platform-specific. +package sandbox + +import "runtime" + +// NetMode selects how the child reaches the network. +type NetMode int + +const ( + // HardFence is the default: no external egress. On macOS a (deny default) SBPL profile permits + // only loopback to the proxy port; on Linux an empty network namespace is bridged to the proxy's + // unix socket. The proxy is the child's only route out. + HardFence NetMode = iota + // SharedNet shares the host network. Used on macOS (loopback is already isolated by SBPL) and as + // the Linux fallback when unprivileged user namespaces are restricted (Ubuntu 24.04). It weakens + // only the network fence; the credential controls (env scrub, keyring block, fs deny) are + // unaffected. + SharedNet +) + +// SandboxSpec is the complete, in-memory policy for one wrapped command. Nothing here is persisted. +type SandboxSpec struct { + // Sandbox=false is the --no-sandbox path: run the child uncontained (proxy + scrubbed env still + // apply). Backends return a plain exec.Cmd in that case. + Sandbox bool + + // ReadPaths are extra readable paths beyond the always-included set (cwd, system libraries). + ReadPaths []string + // WritePaths are extra writable paths beyond the always-included set (cwd, tmp). Write implies read. + WritePaths []string + // DenyPaths are read-denied even though they fall under a broad read allow: the credential files + // (~/.infisical, ~/.aws, ~/.ssh, ...). On Linux they are simply never bound into the namespace. + DenyPaths []string + + // Cwd is the working directory, always readable and writable. + Cwd string + // TempDir is the per-run 0700 scratch dir (CA cert, unix socket, log). Readable inside the box. + TempDir string + + // LoopbackPort is the TCP port the proxy listens on (macOS / Linux shared-net), and the port the + // in-namespace bridge listens on (Linux hard fence). The only permitted egress port on macOS. + LoopbackPort int + // ProxySocket is the pathname unix socket the proxy listens on (Linux hard fence only). When set, + // the bridge forwards LoopbackPort -> this socket. + ProxySocket string + + // Env is the fully-prepared child environment (already scrubbed; proxy + CA + placeholders). + Env []string + + // AllowHosts are extra hostnames the agent may reach; informational for the sandbox layer (host + // policy is enforced by the proxy), carried here for completeness. + AllowHosts []string + + NetMode NetMode +} + +// DefaultDenyPaths returns the credential paths denied by default, resolved against the given home +// directory. Kept here (not in a backend) so both OS backends and the tests agree on the set. +func DefaultDenyPaths(home string) []string { + if home == "" { + return nil + } + rel := []string{ + ".infisical", + ".aws", + ".ssh", + ".config/gcloud", + ".azure", + ".kube", + ".netrc", + ".docker/config.json", + ".config/infisical", + } + paths := make([]string, 0, len(rel)) + for _, r := range rel { + paths = append(paths, home+"/"+r) + } + return paths +} + +// CurrentOS reports the platform the process is running on. Split out so tests can reason about it. +func CurrentOS() string { return runtime.GOOS } From 1cbeeb24ba53cade750133a5e969cec58ec7f3c2 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:21:19 +0530 Subject: [PATCH 03/13] feat(cmd): add `agent-proxy run` local sandboxed mode command Run an untrusted agent on your own machine with Infisical secrets brokered on the wire and an OS sandbox as the trust boundary: infisical secrets agent-proxy run --env --path

-- . Pipeline: resolve the developer's own login (keyring or --token; no machine identity) -> list proxied services (Read Value gate) -> start an ephemeral proxy on loopback or a unix socket with a self-signed local CA -> build a scrubbed child env -> sandbox-wrap -> run with inherited stdio and signal forwarding -> tear down and propagate the exit code. - The child's proxy URL is credential-free; the token and scope stay in the parent. The child env drops INFISICAL_TOKEN, INFISICAL_DOMAIN, and secret-shaped vars (--pass-env re-admits, --set-env injects a literal). - Sandbox toggle resolves from flag or env only, never .infisical.json. - Non-refreshable credentials warn at startup when brokering will stop. - Supported agents' own state dirs (~/.claude, ~/.claude.json, ~/.codex) are writable by default so interactive sessions persist. - Hidden __sandbox-supervisor subcommand runs the in-namespace bridge on Linux. Tests cover the env scrub (no token leak), the credential-free proxy URL, the expiry parse, the secret-shaped-name heuristic, and the agent-state defaults. --- packages/cmd/agent_proxy_run.go | 544 +++++++++++++++++++++ packages/cmd/agent_proxy_run_test.go | 151 ++++++ packages/cmd/agent_proxy_run_token_test.go | 44 ++ packages/cmd/sandbox_supervisor.go | 35 ++ 4 files changed, 774 insertions(+) create mode 100644 packages/cmd/agent_proxy_run.go create mode 100644 packages/cmd/agent_proxy_run_test.go create mode 100644 packages/cmd/agent_proxy_run_token_test.go create mode 100644 packages/cmd/sandbox_supervisor.go diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go new file mode 100644 index 00000000..1e6d54e0 --- /dev/null +++ b/packages/cmd/agent_proxy_run.go @@ -0,0 +1,544 @@ +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/Infisical/infisical-merge/packages/agentproxy" + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" + "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/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var agentProxyRunCmd = &cobra.Command{ + Use: "run [flags] -- [agent start command]", + Short: "Run an untrusted agent locally with secrets brokered on the wire and an OS sandbox as the trust boundary", + Example: "infisical secrets agent-proxy run --env=prod --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 drives the name-based env scrub: any parent env var whose name contains +// one of these (case-insensitive) is removed from the child env unless explicitly re-added with +// --pass-env. This is coarse on purpose (e.g. it scrubs ANTHROPIC_API_KEY); --pass-env is the escape. +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")) + } + + environment, err := cmd.Flags().GetString("env") + if err != nil { + util.HandleError(err, "Unable to parse --env") + } + if !cmd.Flags().Changed("env") { + if envFromWorkspace := util.GetEnvFromWorkspaceFile(); envFromWorkspace != "" { + environment = envFromWorkspace + } + } + if environment == "" { + util.HandleError(fmt.Errorf("the --env flag is required")) + } + + secretPath, err := cmd.Flags().GetString("path") + if err != nil { + util.HandleError(err, "Unable to parse --path") + } + + 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) + + // Resolve the developer's identity. This is the single identity for the whole run: it fetches the + // proxied-service config and the referenced secret values. The child gets none of it. A machine + // identity is kept refreshed for the session; other credential types fail closed at expiry. + 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, + InfisicalHost: infisicalAPIHost(), + IdentityID: src.label, + IdentityName: src.label, + } + + // Per-run 0700 tempdir: only the public CA cert (and, on the Linux hard fence, the unix socket) + // ever touch disk. Removed on exit. + tempDir, err := os.MkdirTemp("", "infisical-agent-proxy-run-") + if err != nil { + util.HandleError(err, "Failed to create the per-run temp directory") + } + defer os.RemoveAll(tempDir) + + home, _ := os.UserHomeDir() + cwd, _ := os.Getwd() + extraRead, _ := cmd.Flags().GetStringArray("allow-read") + extraWrite, _ := cmd.Flags().GetStringArray("allow-write") + allowHosts, _ := cmd.Flags().GetStringArray("allow-host") + + // Supported interactive agents persist their own state under home (Claude Code: ~/.claude and + // ~/.claude.json; Codex: ~/.codex). These are the agent's own data, not the developer's foreign + // secrets, so making them writable is safe and is what a real interactive session needs (without + // it Claude Code runs but cannot save sessions: "transcript writes are failing"). Reads there are + // already allowed; only the write grant is missing by default. + writePaths := append(defaultAgentStateWritePaths(home), extraWrite...) + + spec := sandbox.SandboxSpec{ + Sandbox: sandboxEnabled, + ReadPaths: extraRead, + WritePaths: writePaths, + DenyPaths: sandbox.DefaultDenyPaths(home), + Cwd: cwd, + TempDir: tempDir, + AllowHosts: allowHosts, + // HardFence is the default; the Linux backend downgrades to SharedNet via Preflight when + // unprivileged user namespaces are restricted. macOS always fences egress to loopback via SBPL + // regardless of this field. + NetMode: sandbox.HardFence, + } + + // Preflight decides sandbox availability and, on Linux, hard fence vs shared-net and whether the + // in-namespace bridge is needed. It must run before we choose the proxy listener (unix socket vs + // TCP) and build the child env (the proxy URL host/port depends on the bridge). + backend := sandbox.NewBackend(spec) + pre, err := backend.Preflight(spec) + if err != nil { + util.HandleError(err, "Sandbox preflight failed") + } + if !pre.Supported { + util.HandleError(fmt.Errorf("%s", pre.Reason)) + } + if pre.FallbackToSharedNet { + spec.NetMode = sandbox.SharedNet + util.PrintWarning(fmt.Sprintf("network hard-fence unavailable (%s); falling back to shared host networking. Your credentials remain protected (env scrub, keyring block, filesystem deny); only the network isolation is reduced.", pre.Reason)) + } + + proxy, err := agentproxy.New(agentproxy.Options{ + UnmatchedHost: unmatchedHost, + PollInterval: time.Duration(pollInterval) * time.Second, + Local: local, + AllowedHosts: allowHosts, + }) + if err != nil { + util.HandleError(err, "Failed to initialize the ephemeral agent proxy") + } + + // Choose the proxy transport. Hard fence: a pathname unix socket in the tempdir, bridged into the + // empty netns; the child targets the bridge's fixed loopback port. Otherwise: TCP loopback the + // child reaches directly (macOS SBPL, Linux shared-net, or --no-sandbox). + 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 { + util.HandleError(err, "Failed to bind the ephemeral proxy on its unix socket") + } + 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 { + util.HandleError(err, "Failed to bind the ephemeral proxy on loopback") + } + 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 { + util.HandleError(err, "Failed to write the local 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 (--no-sandbox): the agent is uncontained and can read your keyring, credential files, and reach the network directly. Secrets are still brokered on the wire, but the sandbox boundary is off.") + } else { + log.Info().Msg(color.HiBlackString("Local coupled mode: the proxy acts with your own access and the OS sandbox is the boundary that keeps the agent from reading your credentials directly.")) + } + + child, err := backend.Wrap(spec, args) + if err != nil { + shutdownProxy(proxy) + util.HandleError(err, "Failed to wrap the agent command in the sandbox") + } + + exitCode := runSandboxedChild(child, proxy, proxyErrCh) + shutdownProxy(proxy) + os.Exit(exitCode) +} + +// runSandboxedChild starts the child, forwards signals, and returns its exit code. If the proxy dies +// first, it kills the child (never leave the agent running against a dead proxy). +func runSandboxedChild(child *exec.Cmd, proxy *agentproxy.Proxy, proxyErrCh <-chan error) int { + log.Info().Msg(color.GreenString("Starting agent behind the Infisical agent proxy (sandboxed)")) + + if err := child.Start(); err != nil { + log.Error().Err(err).Msg("failed to start the agent process") + return 1 + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh) + go func() { + for sig := range sigCh { + if child.Process != nil { + _ = child.Process.Signal(sig) + } + } + }() + + waitCh := make(chan error, 1) + go func() { waitCh <- child.Wait() }() + + select { + case err := <-proxyErrCh: + log.Error().Err(err).Msg("the ephemeral proxy stopped unexpectedly; terminating the agent") + if child.Process != nil { + _ = child.Process.Kill() + } + <-waitCh + return 1 + case err := <-waitCh: + signal.Stop(sigCh) + return exitCodeFromWait(err) + } +} + +func exitCodeFromWait(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok { + return ws.ExitStatus() + } + } + log.Error().Err(err).Msg("agent process error") + return 1 +} + +func shutdownProxy(proxy *agentproxy.Proxy) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxy.Shutdown(ctx) +} + +// resolveSandboxEnabled reads the sandbox toggle from the flag or the env var 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. token() returns the current access token (read +// per API request, so a future user-session refresher can rotate it behind this accessor); label +// names the identity in activity records. +type tokenSource struct { + token func() string + label string +} + +// resolveDeveloperTokenSource resolves the single identity for the run. Local mode is a human +// developer acting as themselves, so the only identities are the developer's keyring login or a token +// they already hold. There is intentionally no machine-identity path here: an MI is a +// service/automation identity (used by `agent-proxy start`, `gateway`, the agent daemon), not a +// person running an agent on their laptop. +// +// - --token / env token: used as-is (a raw access token has no renewal material). Fails closed at +// expiry, same as everywhere else in the CLI. +// - Keyring login: the developer's own session. The CLI does not persist a usable refresh token for +// user logins today (UserCredentials.RefreshToken is never populated at login), so this cannot be +// refreshed mid-session either. Fails closed at expiry. +// +// A human's interactive session almost always fits inside the token's lifetime, so both paths just +// warn at startup how long brokering will last. Genuine long-session refresh would mean renewing the +// developer's own session (persist + use the login refresh token) and belongs in the shared login +// flow, not here. +func resolveDeveloperTokenSource(cmd *cobra.Command) tokenSource { + if token, err := util.GetInfisicalToken(cmd); err == nil && token != nil && token.Token != "" { + warnTokenExpiry(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 + warnTokenExpiry(jwt, "your login") + return tokenSource{token: func() string { return jwt }, label: details.UserCredentials.Email} +} + +// warnTokenExpiry prints how long brokering will last for a credential that cannot be refreshed, so a +// session outliving it is a conscious choice. Silent when the expiry can't be read or is comfortably far. +func warnTokenExpiry(jwtToken, subject string) { + exp, ok := jwtExpiry(jwtToken) + if !ok { + return + } + remaining := time.Until(exp) + if remaining <= 0 { + util.PrintWarning(fmt.Sprintf("%s has expired; brokering will fail. Run 'infisical login' or pass a valid --token.", subject)) + return + } + util.PrintWarning(fmt.Sprintf("%s expires in %s (at %s); brokering stops then and cannot be refreshed mid-session. For a longer run, start fresh after 'infisical login'.", + subject, remaining.Round(time.Minute), exp.Local().Format("15:04"))) +} + +// jwtExpiry reads the exp claim from a JWT without verifying the signature (the token was already +// minted by Infisical). Returns false when the token is not 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 lists the proxied services in scope and returns the placeholder env +// to inject. Unlike the remote fetchProxiedServiceConfig it does NOT filter on CanProxy: locally the +// gate is Read Value alone. Disabled services are still skipped (their placeholders would reach +// upstream verbatim). Real secret values are never fetched here; brokering happens on the wire. +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' own state locations under home that +// currently exist, so an interactive agent can persist sessions/config. Only existing paths are +// returned: bwrap --bind fails on a missing source, and there is no reason to grant writes to a +// path the agent isn't using. These are the agent's own data, never the developer's other secrets. +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 +} + +// localProxyURL is the credential-free proxy URL for the child: no userinfo at all. The scope and the +// developer token live only in the parent; the child just needs to know where the proxy is. +func localProxyURL(proxyAddr string) string { + u := url.URL{Scheme: "http", Host: proxyAddr} + return u.String() +} + +// infisicalAPIHost extracts the bare hostname of the configured Infisical API, used by the proxy to +// refuse egress to the control plane from inside the sandbox. +func infisicalAPIHost() string { + u, err := url.Parse(config.INFISICAL_URL) + if err != nil { + return "" + } + return u.Hostname() +} + +// buildLocalAgentEnv builds the child environment for local mode: the credential-free proxy vars, the +// CA trust vars, and the placeholders, on top of a scrubbed copy of the parent env. It deliberately +// omits INFISICAL_TOKEN, INFISICAL_DOMAIN, and every secret-shaped var, so no credential and no real +// secret ever reaches the agent through the environment. Brokered secrets reach it only on the wire. +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 + } + stale[util.INFISICAL_TOKEN_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 + } + + env["HTTPS_PROXY"] = proxy + env["HTTP_PROXY"] = proxy + env["NO_PROXY"] = mergeNoProxy(operatorNoProxy...) + env["NODE_USE_ENV_PROXY"] = "1" + env["OPENCLAW_PROXY_URL"] = proxy + + 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") + agentProxyRunCmd.Flags().String("path", "/", "secret path (folder) scope") + agentProxyRunCmd.Flags().String("projectId", "", "project id (falls back to INFISICAL_PROJECT_ID or .infisical.json)") + agentProxyRunCmd.Flags().String("token", "", "run with this token instead of your keyring login (used as-is; not refreshed)") + agentProxyRunCmd.Flags().Bool("sandbox", true, "run the agent inside the OS sandbox (default on)") + agentProxyRunCmd.Flags().Bool("no-sandbox", false, "disable the OS sandbox (agent runs uncontained; prints a warning)") + agentProxyRunCmd.Flags().String("unmatched-host", "allow", "policy for hosts with no proxied service: allow | block") + agentProxyRunCmd.Flags().Int("poll-interval", 60, "seconds between permission/credential refreshes") + agentProxyRunCmd.Flags().StringArray("allow-read", nil, "extra path the agent may read (repeatable)") + agentProxyRunCmd.Flags().StringArray("allow-write", nil, "extra path the agent may write (repeatable; implies read)") + agentProxyRunCmd.Flags().StringArray("allow-host", nil, "extra host the agent may reach through the proxy (repeatable)") + agentProxyRunCmd.Flags().StringArray("pass-env", nil, "let a specific host env var through to the agent (repeatable)") + agentProxyRunCmd.Flags().StringArray("set-env", nil, "inject a literal KEY=VALUE into the agent (repeatable)") + // --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..a961cebb --- /dev/null +++ b/packages/cmd/agent_proxy_run_test.go @@ -0,0 +1,151 @@ +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") + } + if env["HTTPS_PROXY"] != "http://127.0.0.1:51234" { + t.Errorf("HTTPS_PROXY = %q; want the credential-free proxy URL", env["HTTPS_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/sandbox_supervisor.go b/packages/cmd/sandbox_supervisor.go new file mode 100644 index 00000000..a5081db3 --- /dev/null +++ b/packages/cmd/sandbox_supervisor.go @@ -0,0 +1,35 @@ +//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. It is NOT meant to be run by users: the +// Linux hard-fence path re-execs the CLI binary as this subcommand inside the bwrap network namespace, +// where it brings loopback up, bridges the child's loopback proxy port to the parent's proxy unix +// socket, and then execs the agent. Everything after `--` is the agent command. +var sandboxSupervisorCmd = &cobra.Command{ + Use: sandbox.SupervisorSubcommand, + Hidden: true, + DisableFlagParsing: false, + FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, + DisableFlagsInUseLine: true, + 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) +} From 37aabd03c07ec938aa28bfea9377b2a42650ef74 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:43:18 +0530 Subject: [PATCH 04/13] fix(agent-proxy): address review findings in local run mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Linux: mask credential files with a /dev/null bind and directories with an empty tmpfs. Mounting a tmpfs onto an existing file aborts bwrap at startup (ENOTDIR), which would have blocked the sandbox for anyone with a regular-file deny path such as ~/.docker/config.json or ~/.netrc. buildBwrapArgv takes an injected file classifier so it stays a pure, golden-testable function; the Linux backend passes a real os.Stat-based one. - Linux: the in-namespace supervisor overrides the root PersistentPreRun with a no-op, so the internal re-exec no longer runs the human-facing preamble (update check, notices, keyring read) — a network call — inside the empty network namespace, where it wasted the timeout and printed stray notices. - run: clean up the per-run temp dir explicitly at every exit path. os.Exit and util.HandleError skip deferred funcs, so the previous `defer os.RemoveAll` never fired and a temp dir leaked on every run. - run: drop a stale comment describing the removed machine-identity refresh path. Golden test covers the file-vs-directory masking. Builds on darwin and linux; existing suites and the macOS live enforcement tests pass. --- packages/cmd/agent_proxy_run.go | 24 ++++++++++++--------- packages/cmd/sandbox_supervisor.go | 5 +++++ packages/sandbox/bwrap.go | 9 +++++++- packages/sandbox/bwrap_args.go | 24 +++++++++++++++------ packages/sandbox/bwrap_args_test.go | 33 +++++++++++++++++++++++++++-- 5 files changed, 76 insertions(+), 19 deletions(-) diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go index 1e6d54e0..56bf08df 100644 --- a/packages/cmd/agent_proxy_run.go +++ b/packages/cmd/agent_proxy_run.go @@ -92,8 +92,7 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { sandboxEnabled := resolveSandboxEnabled(cmd) // Resolve the developer's identity. This is the single identity for the whole run: it fetches the - // proxied-service config and the referenced secret values. The child gets none of it. A machine - // identity is kept refreshed for the session; other credential types fail closed at expiry. + // proxied-service config and the referenced secret values. The child gets none of it. src := resolveDeveloperTokenSource(cmd) httpClient := resty.New().SetAuthToken(src.token()) @@ -115,7 +114,11 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { if err != nil { util.HandleError(err, "Failed to create the per-run temp directory") } - defer os.RemoveAll(tempDir) + // os.Exit (at the end, and inside util.HandleError) does not run deferred funcs, so a `defer` + // here would never fire. Clean up explicitly at each exit path instead. fail() removes the temp + // dir before delegating to HandleError (which exits) so setup failures don't leak it either. + cleanup := func() { _ = os.RemoveAll(tempDir) } + fail := func(e error, messages ...string) { cleanup(); util.HandleError(e, messages...) } home, _ := os.UserHomeDir() cwd, _ := os.Getwd() @@ -150,10 +153,10 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { backend := sandbox.NewBackend(spec) pre, err := backend.Preflight(spec) if err != nil { - util.HandleError(err, "Sandbox preflight failed") + fail(err, "Sandbox preflight failed") } if !pre.Supported { - util.HandleError(fmt.Errorf("%s", pre.Reason)) + fail(fmt.Errorf("%s", pre.Reason)) } if pre.FallbackToSharedNet { spec.NetMode = sandbox.SharedNet @@ -167,7 +170,7 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { AllowedHosts: allowHosts, }) if err != nil { - util.HandleError(err, "Failed to initialize the ephemeral agent proxy") + fail(err, "Failed to initialize the ephemeral agent proxy") } // Choose the proxy transport. Hard fence: a pathname unix socket in the tempdir, bridged into the @@ -179,14 +182,14 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { spec.ProxySocket = filepath.Join(tempDir, "proxy.sock") listener, err = net.Listen("unix", spec.ProxySocket) if err != nil { - util.HandleError(err, "Failed to bind the ephemeral proxy on its unix socket") + fail(err, "Failed to bind the ephemeral proxy on its unix socket") } 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 { - util.HandleError(err, "Failed to bind the ephemeral proxy on loopback") + fail(err, "Failed to bind the ephemeral proxy on loopback") } spec.LoopbackPort = listener.Addr().(*net.TCPAddr).Port childProxyURL = localProxyURL(listener.Addr().String()) @@ -194,7 +197,7 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { caPath := filepath.Join(tempDir, "local-ca.pem") if err := os.WriteFile(caPath, proxy.LocalRootPEM(), 0o600); err != nil { - util.HandleError(err, "Failed to write the local CA certificate") + fail(err, "Failed to write the local CA certificate") } proxyErrCh := make(chan error, 1) @@ -211,11 +214,12 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { child, err := backend.Wrap(spec, args) if err != nil { shutdownProxy(proxy) - util.HandleError(err, "Failed to wrap the agent command in the sandbox") + fail(err, "Failed to wrap the agent command in the sandbox") } exitCode := runSandboxedChild(child, proxy, proxyErrCh) shutdownProxy(proxy) + cleanup() os.Exit(exitCode) } diff --git a/packages/cmd/sandbox_supervisor.go b/packages/cmd/sandbox_supervisor.go index a5081db3..aaec2eef 100644 --- a/packages/cmd/sandbox_supervisor.go +++ b/packages/cmd/sandbox_supervisor.go @@ -19,6 +19,11 @@ var sandboxSupervisorCmd = &cobra.Command{ DisableFlagParsing: false, FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, DisableFlagsInUseLine: true, + // Override the root PersistentPreRun so this internal re-exec does NOT run the human-facing + // preamble (update check, package-repo notice, keyring read). Those make a network call, and this + // runs inside an empty network namespace where that call would waste the timeout and print stray + // notices. The supervisor needs none of it; it only brings loopback up, bridges, and execs. + PersistentPreRun: func(_ *cobra.Command, _ []string) {}, Run: func(cmd *cobra.Command, args []string) { probe, _ := cmd.Flags().GetBool("probe") port, _ := cmd.Flags().GetInt("port") diff --git a/packages/sandbox/bwrap.go b/packages/sandbox/bwrap.go index 92c6cf6c..e526011d 100644 --- a/packages/sandbox/bwrap.go +++ b/packages/sandbox/bwrap.go @@ -10,6 +10,13 @@ import ( func osBackend() Backend { return bwrapBackend{} } +// regularFileExists reports whether path currently exists as a regular file (following symlinks). +// Deny paths that are regular files must be masked with a /dev/null bind, not a tmpfs. +func regularFileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + type bwrapBackend struct{} // Preflight checks bwrap is installed and decides hard fence vs shared-net fallback. The decision is @@ -77,7 +84,7 @@ func (bwrapBackend) Wrap(spec SandboxSpec, argv []string) (*exec.Cmd, error) { return nil, fmt.Errorf("cannot resolve own executable for the sandbox supervisor: %w", err) } - full := buildBwrapArgv(spec, self, argv) + full := buildBwrapArgv(spec, self, argv, regularFileExists) // full[0] is the literal "bwrap"; exec with the resolved path but keep argv[0] as bwrap. // #nosec G204 -- the wrapped command is provided directly by the operator running the CLI cmd := exec.Command(bwrapPath, full[1:]...) diff --git a/packages/sandbox/bwrap_args.go b/packages/sandbox/bwrap_args.go index 57fcf73b..1b5c61f6 100644 --- a/packages/sandbox/bwrap_args.go +++ b/packages/sandbox/bwrap_args.go @@ -10,12 +10,19 @@ const BridgeLoopbackPort = 17321 // cmd package registers a matching hidden cobra command. const SupervisorSubcommand = "__sandbox-supervisor" -// buildBwrapArgv builds the full argv (starting with "bwrap") for a spec. Pure function, no OS calls, -// so it is golden-testable on any platform. +// buildBwrapArgv builds the full argv (starting with "bwrap") for a spec. Pure function (its only +// filesystem dependency is injected via isFile), so it is golden-testable on any platform. // // - selfExe is the path to this binary (/proc/self/exe at call time), re-executed as the supervisor // on the hard-fence path. // - argv is the agent command. +// - isFile reports whether a deny path currently exists as a regular file, which decides how it is +// masked (see below). Injected so tests can exercise both branches without touching disk. +// +// Deny paths are masked by their type: a directory (or a path that does not exist) is covered with an +// empty --tmpfs; a regular file is covered by binding /dev/null over it. Using the wrong primitive is +// not cosmetic: bwrap aborts at startup if asked to mount a tmpfs onto a file (ENOTDIR), so a user who +// has e.g. ~/.docker/config.json would otherwise be unable to start the sandbox at all. // // Hard fence (NetMode == HardFence): --unshare-all gives an empty network namespace; the proxy's unix // socket (spec.ProxySocket) is bind-mounted in and the supervisor bridges LoopbackPort -> that socket. @@ -23,7 +30,7 @@ const SupervisorSubcommand = "__sandbox-supervisor" // reaches the proxy on host loopback directly and no supervisor/bridge is inserted. // // Deliberately omits --new-session (it calls setsid() and breaks the interactive TTY / job control). -func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string) []string { +func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string, isFile func(string) bool) []string { args := []string{ "bwrap", "--unshare-all", @@ -40,10 +47,15 @@ func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string) []string { args = append(args, "--share-net") } - // Mask credential paths: an empty tmpfs on top of each hides the real contents (they were visible - // via --ro-bind / /). tmpfs creates the mountpoint, so a non-existent path is harmless. + // Mask credential paths (they were visible via --ro-bind / /). A regular file is masked by binding + // /dev/null over it; a directory (or a path that does not exist) is masked with an empty tmpfs. + // tmpfs onto an existing file makes bwrap abort at startup, so the file/dir split is load-bearing. for _, p := range dedupeSorted(spec.DenyPaths) { - args = append(args, "--tmpfs", p) + if isFile(p) { + args = append(args, "--ro-bind", "/dev/null", p) + } else { + args = append(args, "--tmpfs", p) + } } // Writable workspace + tempdir, bound AFTER --tmpfs /tmp so a tempdir under /tmp is not shadowed. diff --git a/packages/sandbox/bwrap_args_test.go b/packages/sandbox/bwrap_args_test.go index 8ce16361..721b31b3 100644 --- a/packages/sandbox/bwrap_args_test.go +++ b/packages/sandbox/bwrap_args_test.go @@ -18,6 +18,10 @@ func bwrapSpec(net NetMode) SandboxSpec { } } +// allDirs classifies every deny path as a directory (never a file), so the default specs mask with +// --tmpfs. Tests that care about the file branch pass their own classifier. +func allDirs(string) bool { return false } + // argIndex returns the index of the first occurrence of tok, or -1. func argIndex(args []string, tok string) int { for i, a := range args { @@ -29,7 +33,7 @@ func argIndex(args []string, tok string) int { } func TestBwrapArgvHardFence(t *testing.T) { - args := buildBwrapArgv(bwrapSpec(HardFence), "/proc/self/exe", []string{"claude", "--flag"}) + args := buildBwrapArgv(bwrapSpec(HardFence), "/proc/self/exe", []string{"claude", "--flag"}, allDirs) joined := strings.Join(args, " ") for _, want := range []string{ @@ -70,7 +74,7 @@ func TestBwrapArgvHardFence(t *testing.T) { } func TestBwrapArgvSharedNet(t *testing.T) { - args := buildBwrapArgv(bwrapSpec(SharedNet), "/proc/self/exe", []string{"claude"}) + args := buildBwrapArgv(bwrapSpec(SharedNet), "/proc/self/exe", []string{"claude"}, allDirs) joined := strings.Join(args, " ") if !strings.Contains(joined, "--share-net") { @@ -96,6 +100,31 @@ func lastArgPairIndex(args []string, flag, val string) int { 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. + isFile := func(p string) bool { + return p == "/home/dev/.docker/config.json" || p == "/home/dev/.netrc" + } + joined := strings.Join(buildBwrapArgv(spec, "/proc/self/exe", []string{"claude"}, isFile), " ") + + // 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 TestItoa(t *testing.T) { cases := map[int]string{0: "0", 7: "7", 17321: "17321", -42: "-42"} for in, want := range cases { From fa90198ff4006e49980d51f43f5db5469f000c63 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:03:45 +0530 Subject: [PATCH 05/13] chore(agent-proxy): trim comments to house style, drop dead code Cut the verbose explanatory comments added with the feature down to short notes only where the code isn't self-evident, matching the surrounding style. Also remove two bits of dead code found while trimming: the unused sandbox.CurrentOS helper and the SandboxSpec.AllowHosts field (host allow-listing is enforced by the proxy via Options.AllowedHosts, not the sandbox). No behavior change. --- packages/agentproxy/ca.go | 16 ++-- packages/agentproxy/cache.go | 9 +-- packages/agentproxy/local.go | 33 ++++----- packages/agentproxy/proxy.go | 38 ++++------ packages/cmd/agent_proxy_run.go | 98 ++++++++----------------- packages/cmd/sandbox_supervisor.go | 12 +-- packages/sandbox/bridge.go | 22 ++---- packages/sandbox/bwrap.go | 15 +--- packages/sandbox/bwrap_args.go | 46 +++--------- packages/sandbox/sandbox.go | 31 ++------ packages/sandbox/sandbox_unsupported.go | 4 +- packages/sandbox/seatbelt.go | 4 +- packages/sandbox/seatbelt_profile.go | 31 ++------ packages/sandbox/spec.go | 67 +++++------------ 14 files changed, 127 insertions(+), 299 deletions(-) diff --git a/packages/agentproxy/ca.go b/packages/agentproxy/ca.go index cfb4e9a9..3a56b3f0 100644 --- a/packages/agentproxy/ca.go +++ b/packages/agentproxy/ca.go @@ -28,17 +28,14 @@ const ( leafReuseMargin = 1 * time.Hour maxLeafCacheEntries = 8192 - // localRootTTL bounds the self-signed local root used by `agent-proxy run`. The root lives only in - // memory for the lifetime of one wrapped command; the TTL just needs to comfortably outlast any - // single agent session. + // 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 marks the self-signed local-root mode used by `agent-proxy run`: the "intermediate" fields - // hold a self-signed root minted at construction, never re-signed via Infisical, never persisted. + // local: the intermediate fields hold a self-signed root minted at construction, never re-signed. local bool mu sync.Mutex @@ -64,10 +61,8 @@ func newCaManager(token func() string) *caManager { } } -// newLocalCaManager builds the CA for local coupled mode: a self-signed ECDSA P-256 root generated in -// memory, from which mintLeaf signs per-host leaves directly. P-256 is deliberate (rustls-based agents -// reject exotic curves). The private key never leaves process memory; only RootPEM (public) may be -// written out for the child's trust bundle. +// newLocalCaManager builds an in-memory self-signed ECDSA P-256 root (P-256 so rustls-based agents +// accept it) that mintLeaf signs leaves from directly. The private key never leaves memory. func newLocalCaManager() (*caManager, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -104,8 +99,7 @@ func newLocalCaManager() (*caManager, error) { }, nil } -// RootPEM returns the public certificate of the local root (nil outside local mode). Public only: -// safe to write to the per-run tempdir for SSL_CERT_FILE / NODE_EXTRA_CA_CERTS. +// RootPEM returns the local root's public certificate (nil outside local mode). func (c *caManager) RootPEM() []byte { if !c.local { return nil diff --git a/packages/agentproxy/cache.go b/packages/agentproxy/cache.go index 35f1d6f1..abc254b7 100644 --- a/packages/agentproxy/cache.go +++ b/packages/agentproxy/cache.go @@ -219,10 +219,7 @@ func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService, }) } -// resolveParams parameterizes the pieces of service resolution that differ between the two resolver -// implementations: remote (agentCache: discovery with the agent's wire JWT, values with the proxy MI, -// Proxy permission required, dynamic secrets leased) and local (localResolver: one developer token for -// both, Read Value is the only gate, dynamic secrets skipped). +// resolveParams holds what differs between the remote (agentCache) and local (localResolver) resolvers. type resolveParams struct { discoveryToken string valueToken func() string @@ -231,8 +228,8 @@ type resolveParams struct { registerDynamic func(cred api.ProxiedServiceCredential, projectSlug string) *dynamicCredentialRef } -// resolveServices turns the proxied-services list for a scope into resolvedServices with credential -// values attached. Shared by both resolver implementations; behavior differences live in resolveParams. +// 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{ diff --git a/packages/agentproxy/local.go b/packages/agentproxy/local.go index 1379e95a..08d164d0 100644 --- a/packages/agentproxy/local.go +++ b/packages/agentproxy/local.go @@ -8,25 +8,21 @@ import ( ) // LocalOptions switches the proxy into local coupled mode (`agent-proxy run`): one developer, one -// sandboxed agent, one scope, for the lifetime of one wrapped command. In this mode the proxy accepts -// requests without Proxy-Authorization (the wire carries no credential at all; the child env must -// never hold a token), serves every request under the scope fixed here, and performs all Infisical -// API calls with the developer's own token. +// agent, one scope. Requests carry no Proxy-Authorization; every request uses the scope and token +// fixed here, and API calls use the developer's own token. type LocalOptions struct { ProjectID string Environment string SecretPath string - // UserToken returns the developer's current access token (keyring login or INFISICAL_TOKEN). - // It is called per API request so a refresher can rotate the token behind it. + // UserToken returns the developer's current access token; called per request so it can rotate. UserToken func() string - // InfisicalHost is the hostname (no scheme/port) of the Infisical API. Egress to it through the - // proxy is always refused: the control plane must stay unreachable from the sandbox as an - // invariant, not merely because the child holds no token. + // InfisicalHost (bare hostname) is always refused through the proxy, keeping the control plane + // unreachable from the sandbox as an invariant, not just because the child holds no token. InfisicalHost string - // IdentityID and IdentityName label activity records; there is no wire JWT to decode them from. + // IdentityID/IdentityName label activity records (no wire JWT to decode them from). IdentityID string IdentityName string } @@ -35,10 +31,9 @@ func (l *LocalOptions) scope() agentScope { return agentScope{projectID: l.ProjectID, environment: l.Environment, secretPath: l.SecretPath} } -// localResolver is the local-mode counterpart of agentCache: a single snapshot of resolved services -// for the fixed startup scope instead of a keyed multi-agent cache. There is no entry map, eviction, -// or TTL bookkeeping because the one caller is known before the proxy starts. The snapshot refreshes -// on the poll loop and is dropped (fail closed) when the developer's authorization goes away. +// localResolver is the single-snapshot, single-scope counterpart of agentCache (no keyed map, since +// the one caller is fixed at startup). The snapshot refreshes on the poll loop and is dropped +// (fail closed) when the developer's authorization goes away. type localResolver struct { opts *LocalOptions @@ -74,9 +69,8 @@ func (l *localResolver) get(_ string, _ agentScope) ([]*resolvedService, error) } func (l *localResolver) resolveSnapshot() ([]*resolvedService, error) { - // One identity for discovery and value-fetch (the identity collapse), and no CanProxy filter: - // locally the gate is Read Value alone, so a visible service whose secrets the developer can read - // is brokered even without the Proxy permission. + // 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, @@ -123,13 +117,12 @@ func (l *localResolver) refreshActive() { l.mu.Unlock() } -// activeJWTs exists for the lease refresh loop; local mode never registers leases. +// activeJWTs is for the lease loop; local mode registers no leases. func (l *localResolver) activeJWTs() map[string]struct{} { return map[string]struct{}{} } -// close drops the snapshot so credential values are unreachable; the next request must re-resolve -// (and fails if the developer's authorization is gone). Also the fail-closed path for refreshActive. +// close drops the snapshot (fail closed); the next request must re-resolve. func (l *localResolver) close() { l.mu.Lock() defer l.mu.Unlock() diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index 18eee04e..1371e60b 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -77,18 +77,14 @@ type Options struct { PollInterval time.Duration ProxyToken func() string - // Local switches the proxy into local coupled mode (`agent-proxy run`): no Proxy-Authorization on - // the wire, one fixed scope, self-signed local root CA, developer-token resolution. Nil = remote. + // Local switches the proxy into local coupled mode; nil = remote. Local *LocalOptions - // AllowedHosts are extra hostnames that pass through (no credential injected) even when - // UnmatchedHost is block. Used by `run --allow-host`; empty otherwise. + // AllowedHosts pass through with no credential even under UnmatchedBlock (run --allow-host). AllowedHosts []string } -// serviceResolver is the proxy's view of credential resolution. Two implementations: agentCache -// (remote: many agents keyed by wire JWT+scope) and localResolver (local coupled mode: one snapshot -// for one known user). close drops all cached credential values at shutdown. +// 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) @@ -158,9 +154,8 @@ func (ps *proxyServer) newFrontServer() *http.Server { } } -// Proxy is a lifecycle-controllable agent proxy instance: the caller owns binding (any net.Listener, -// including loopback :0 and pathname unix sockets), serving, and shutdown. `agent-proxy run` drives -// this directly; the remote `start` command keeps using the blocking Start wrapper below. +// 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 @@ -183,8 +178,7 @@ func New(opts Options) (*Proxy, error) { }, nil } -// Serve starts the poll and lease loops and serves on ln until Shutdown (returns nil) or a serve -// error. The listener is owned by the caller until passed here, then closed by the server. +// 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.cache.activeJWTs) @@ -196,8 +190,8 @@ func (p *Proxy) Serve(ln net.Listener) error { return err } -// Shutdown stops the loops, revokes active leases, drains in-flight requests (bounded by ctx), and -// drops all cached credential values. Safe to call more than once. +// 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() { @@ -211,8 +205,7 @@ func (p *Proxy) Shutdown(ctx context.Context) error { return err } -// LocalRootPEM returns the public certificate of the local self-signed root CA (local mode only; -// nil otherwise). This is what the run command writes to the tempdir for the child's trust bundle. +// LocalRootPEM returns the local root CA's public cert (local mode only; nil otherwise). func (p *Proxy) LocalRootPEM() []byte { return p.ps.ca.RootPEM() } @@ -275,10 +268,8 @@ func (ps *proxyServer) pollLoop(stop <-chan struct{}) { } } -// requestScope resolves the scope and wire credential for a request. Remote mode requires -// Proxy-Authorization (a shared proxy must know which agent and scope each request belongs to). -// Local mode serves one known user under the scope fixed at startup, so no wire credential exists; -// any Proxy-Authorization header the client happens to send is ignored. +// 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 @@ -539,9 +530,7 @@ type forwardOutcome struct { } func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt string, scope agentScope) (*http.Response, forwardOutcome, error) { - // Local mode invariant: the control plane is never reachable from the sandboxed agent, even under - // --unmatched-host allow. The child holds no token so such calls could only fail anyway; blocking - // makes the failure legible and keeps the guarantee independent of the env staying tokenless. + // Local mode: the Infisical control plane is never reachable from the sandbox, even under allow. if l := ps.opts.Local; l != nil && l.InfisicalHost != "" && strings.EqualFold(hostname, l.InfisicalHost) { return nil, forwardOutcome{}, fmt.Errorf("host %q is the Infisical API and is not reachable from the sandboxed agent: %w", hostname, errHostBlocked) } @@ -588,8 +577,7 @@ func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt st return resp, outcome, nil } -// hostAllowlisted reports whether hostname is in the operator's --allow-host set (case-insensitive). -// These pass through under UnmatchedBlock with no credential injected. +// 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) { diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go index 56bf08df..6abb19eb 100644 --- a/packages/cmd/agent_proxy_run.go +++ b/packages/cmd/agent_proxy_run.go @@ -40,9 +40,8 @@ var agentProxyRunCmd = &cobra.Command{ Run: runAgentProxyRun, } -// secretShapedEnvSubstrings drives the name-based env scrub: any parent env var whose name contains -// one of these (case-insensitive) is removed from the child env unless explicitly re-added with -// --pass-env. This is coarse on purpose (e.g. it scrubs ANTHROPIC_API_KEY); --pass-env is the escape. +// 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", } @@ -91,8 +90,7 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { sandboxEnabled := resolveSandboxEnabled(cmd) - // Resolve the developer's identity. This is the single identity for the whole run: it fetches the - // proxied-service config and the referenced secret values. The child gets none of it. + // 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()) @@ -108,15 +106,12 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { IdentityName: src.label, } - // Per-run 0700 tempdir: only the public CA cert (and, on the Linux hard fence, the unix socket) - // ever touch disk. Removed on exit. + // 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, "Failed to create the per-run temp directory") } - // os.Exit (at the end, and inside util.HandleError) does not run deferred funcs, so a `defer` - // here would never fire. Clean up explicitly at each exit path instead. fail() removes the temp - // dir before delegating to HandleError (which exits) so setup failures don't leak it either. + // 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...) } @@ -126,11 +121,8 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { extraWrite, _ := cmd.Flags().GetStringArray("allow-write") allowHosts, _ := cmd.Flags().GetStringArray("allow-host") - // Supported interactive agents persist their own state under home (Claude Code: ~/.claude and - // ~/.claude.json; Codex: ~/.codex). These are the agent's own data, not the developer's foreign - // secrets, so making them writable is safe and is what a real interactive session needs (without - // it Claude Code runs but cannot save sessions: "transcript writes are failing"). Reads there are - // already allowed; only the write grant is missing by default. + // Supported agents persist their own state under home; make those dirs writable so interactive + // sessions can save. It's the agent's own data, not the developer's secrets. writePaths := append(defaultAgentStateWritePaths(home), extraWrite...) spec := sandbox.SandboxSpec{ @@ -140,16 +132,12 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { DenyPaths: sandbox.DefaultDenyPaths(home), Cwd: cwd, TempDir: tempDir, - AllowHosts: allowHosts, - // HardFence is the default; the Linux backend downgrades to SharedNet via Preflight when - // unprivileged user namespaces are restricted. macOS always fences egress to loopback via SBPL - // regardless of this field. + // Linux downgrades to SharedNet via Preflight below; macOS always fences to loopback via SBPL. NetMode: sandbox.HardFence, } - // Preflight decides sandbox availability and, on Linux, hard fence vs shared-net and whether the - // in-namespace bridge is needed. It must run before we choose the proxy listener (unix socket vs - // TCP) and build the child env (the proxy URL host/port depends on the bridge). + // 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 { @@ -173,9 +161,7 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { fail(err, "Failed to initialize the ephemeral agent proxy") } - // Choose the proxy transport. Hard fence: a pathname unix socket in the tempdir, bridged into the - // empty netns; the child targets the bridge's fixed loopback port. Otherwise: TCP loopback the - // child reaches directly (macOS SBPL, Linux shared-net, or --no-sandbox). + // 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 { @@ -223,8 +209,8 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { os.Exit(exitCode) } -// runSandboxedChild starts the child, forwards signals, and returns its exit code. If the proxy dies -// first, it kills the child (never leave the agent running against a dead proxy). +// 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 { log.Info().Msg(color.GreenString("Starting agent behind the Infisical agent proxy (sandboxed)")) @@ -279,8 +265,8 @@ func shutdownProxy(proxy *agentproxy.Proxy) { _ = proxy.Shutdown(ctx) } -// resolveSandboxEnabled reads the sandbox toggle from the flag or the env var only, never -// .infisical.json (a committed file must not be able to silently disable the boundary). +// 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") @@ -296,30 +282,16 @@ func resolveSandboxEnabled(cmd *cobra.Command) bool { return true } -// tokenSource is the parent-held identity for a run. token() returns the current access token (read -// per API request, so a future user-session refresher can rotate it behind this accessor); label -// names the identity in activity records. +// tokenSource is the parent-held identity for a run. token() is read per request so a future +// refresher can rotate it; label names the identity in activity records. type tokenSource struct { token func() string label string } -// resolveDeveloperTokenSource resolves the single identity for the run. Local mode is a human -// developer acting as themselves, so the only identities are the developer's keyring login or a token -// they already hold. There is intentionally no machine-identity path here: an MI is a -// service/automation identity (used by `agent-proxy start`, `gateway`, the agent daemon), not a -// person running an agent on their laptop. -// -// - --token / env token: used as-is (a raw access token has no renewal material). Fails closed at -// expiry, same as everywhere else in the CLI. -// - Keyring login: the developer's own session. The CLI does not persist a usable refresh token for -// user logins today (UserCredentials.RefreshToken is never populated at login), so this cannot be -// refreshed mid-session either. Fails closed at expiry. -// -// A human's interactive session almost always fits inside the token's lifetime, so both paths just -// warn at startup how long brokering will last. Genuine long-session refresh would mean renewing the -// developer's own session (persist + use the login refresh token) and belongs in the shared login -// flow, not here. +// resolveDeveloperTokenSource resolves the run's identity: an explicit --token/env, else the keyring +// login. No machine-identity path (that's for services, not a person on a laptop). Neither can be +// refreshed mid-session, so warnTokenExpiry flags when brokering will stop. func resolveDeveloperTokenSource(cmd *cobra.Command) tokenSource { if token, err := util.GetInfisicalToken(cmd); err == nil && token != nil && token.Token != "" { warnTokenExpiry(token.Token, "the provided token") @@ -338,8 +310,7 @@ func resolveDeveloperTokenSource(cmd *cobra.Command) tokenSource { return tokenSource{token: func() string { return jwt }, label: details.UserCredentials.Email} } -// warnTokenExpiry prints how long brokering will last for a credential that cannot be refreshed, so a -// session outliving it is a conscious choice. Silent when the expiry can't be read or is comfortably far. +// warnTokenExpiry prints when brokering will stop; silent when the expiry can't be read. func warnTokenExpiry(jwtToken, subject string) { exp, ok := jwtExpiry(jwtToken) if !ok { @@ -354,8 +325,7 @@ func warnTokenExpiry(jwtToken, subject string) { subject, remaining.Round(time.Minute), exp.Local().Format("15:04"))) } -// jwtExpiry reads the exp claim from a JWT without verifying the signature (the token was already -// minted by Infisical). Returns false when the token is not a readable JWT (e.g. a service token). +// 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 { @@ -374,10 +344,8 @@ func jwtExpiry(token string) (time.Time, bool) { return time.Unix(claims.Exp, 0), true } -// fetchLocalProxiedServiceConfig lists the proxied services in scope and returns the placeholder env -// to inject. Unlike the remote fetchProxiedServiceConfig it does NOT filter on CanProxy: locally the -// gate is Read Value alone. Disabled services are still skipped (their placeholders would reach -// upstream verbatim). Real secret values are never fetched here; brokering happens on the wire. +// 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, @@ -402,10 +370,8 @@ func fetchLocalProxiedServiceConfig(httpClient *resty.Client, projectID, environ return placeholders } -// defaultAgentStateWritePaths returns the supported agents' own state locations under home that -// currently exist, so an interactive agent can persist sessions/config. Only existing paths are -// returned: bwrap --bind fails on a missing source, and there is no reason to grant writes to a -// path the agent isn't using. These are the agent's own data, never the developer's other secrets. +// 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 @@ -424,15 +390,13 @@ func defaultAgentStateWritePaths(home string) []string { return out } -// localProxyURL is the credential-free proxy URL for the child: no userinfo at all. The scope and the -// developer token live only in the parent; the child just needs to know where the proxy is. +// 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() } -// infisicalAPIHost extracts the bare hostname of the configured Infisical API, used by the proxy to -// refuse egress to the control plane from inside the sandbox. +// infisicalAPIHost is the bare hostname of the configured Infisical API (the proxy refuses egress to it). func infisicalAPIHost() string { u, err := url.Parse(config.INFISICAL_URL) if err != nil { @@ -441,10 +405,8 @@ func infisicalAPIHost() string { return u.Hostname() } -// buildLocalAgentEnv builds the child environment for local mode: the credential-free proxy vars, the -// CA trust vars, and the placeholders, on top of a scrubbed copy of the parent env. It deliberately -// omits INFISICAL_TOKEN, INFISICAL_DOMAIN, and every secret-shaped var, so no credential and no real -// secret ever reaches the agent through the environment. Brokered secrets reach it only on the wire. +// 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") diff --git a/packages/cmd/sandbox_supervisor.go b/packages/cmd/sandbox_supervisor.go index aaec2eef..4b90ab1a 100644 --- a/packages/cmd/sandbox_supervisor.go +++ b/packages/cmd/sandbox_supervisor.go @@ -9,20 +9,16 @@ import ( "github.com/spf13/cobra" ) -// sandboxSupervisorCmd is an internal, hidden entry point. It is NOT meant to be run by users: the -// Linux hard-fence path re-execs the CLI binary as this subcommand inside the bwrap network namespace, -// where it brings loopback up, bridges the child's loopback proxy port to the parent's proxy unix -// socket, and then execs the agent. Everything after `--` is the agent command. +// 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, DisableFlagParsing: false, FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, DisableFlagsInUseLine: true, - // Override the root PersistentPreRun so this internal re-exec does NOT run the human-facing - // preamble (update check, package-repo notice, keyring read). Those make a network call, and this - // runs inside an empty network namespace where that call would waste the timeout and print stray - // notices. The supervisor needs none of it; it only brings loopback up, bridges, and execs. + // 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") diff --git a/packages/sandbox/bridge.go b/packages/sandbox/bridge.go index 10f36db9..3c317c6b 100644 --- a/packages/sandbox/bridge.go +++ b/packages/sandbox/bridge.go @@ -15,13 +15,9 @@ import ( "golang.org/x/sys/unix" ) -// RunSupervisor is the entry point for the hidden `__sandbox-supervisor` subcommand. It runs INSIDE -// the bwrap network namespace (re-exec'd by the bwrap argv on the hard-fence path). It brings loopback -// up, starts a TCP->unix bridge so the child's HTTP(S)_PROXY (127.0.0.1:port) reaches the parent's -// proxy unix socket, then execs the agent as its child and forwards signals / propagates exit code. -// -// probe=true is the capability check used by Preflight: bring loopback up and return the result -// without starting a bridge or agent. +// 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 { @@ -39,8 +35,7 @@ func RunSupervisor(probe bool, port int, socket string, argv []string) int { return 1 } - // Fail-loud bind: if we cannot own the loopback proxy port, abort rather than let the agent's - // traffic flow to whatever is already there (a same-namespace MITM risk). + // 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) @@ -85,9 +80,8 @@ func RunSupervisor(probe bool, port int, socket string, argv []string) int { return 1 } -// runBridge accepts loopback TCP connections and forwards each to the parent's proxy unix socket, -// copying bytes both directions. The socket must be a pathname socket (bind-mounted in); abstract -// sockets are network-namespace-scoped and would be unreachable. +// 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 { @@ -113,8 +107,7 @@ func bridgeConn(client net.Conn, socket string) { <-done } -// bringLoopbackUp sets the loopback interface UP inside the current network namespace. An empty netns -// starts with lo DOWN, so nothing (not even 127.0.0.1) works until this runs. +// bringLoopbackUp brings lo UP; an empty netns starts with it DOWN, so 127.0.0.1 is dead until then. func bringLoopbackUp() error { fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0) if err != nil { @@ -129,7 +122,6 @@ func bringLoopbackUp() error { if err := unix.IoctlIfreq(fd, unix.SIOCGIFFLAGS, ifr); err != nil { return err } - // Set IFF_UP | IFF_RUNNING on the interface flags. flags := ifr.Uint16() | unix.IFF_UP | unix.IFF_RUNNING ifr.SetUint16(flags) return unix.IoctlIfreq(fd, unix.SIOCSIFFLAGS, ifr) diff --git a/packages/sandbox/bwrap.go b/packages/sandbox/bwrap.go index e526011d..5f9c6200 100644 --- a/packages/sandbox/bwrap.go +++ b/packages/sandbox/bwrap.go @@ -10,8 +10,6 @@ import ( func osBackend() Backend { return bwrapBackend{} } -// regularFileExists reports whether path currently exists as a regular file (following symlinks). -// Deny paths that are regular files must be masked with a /dev/null bind, not a tmpfs. func regularFileExists(path string) bool { info, err := os.Stat(path) return err == nil && info.Mode().IsRegular() @@ -19,10 +17,8 @@ func regularFileExists(path string) bool { type bwrapBackend struct{} -// Preflight checks bwrap is installed and decides hard fence vs shared-net fallback. The decision is -// made by actually attempting an empty-netns bwrap invocation (probe-by-execution), which is more -// reliable across distros than parsing sysctls: on Ubuntu 24.04+ the AppArmor -// kernel.apparmor_restrict_unprivileged_userns restriction makes the probe fail, and we fall back. +// 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 SandboxSpec) (PreflightResult, error) { bwrapPath, err := exec.LookPath("bwrap") if err != nil { @@ -51,8 +47,7 @@ func (bwrapBackend) Preflight(spec SandboxSpec) (PreflightResult, error) { }, nil } -// hardFenceWorks probes whether an empty-netns bwrap can start and bring loopback up, by running the -// binary in the supervisor's probe mode inside the sandbox. +// 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 { @@ -68,9 +63,6 @@ func hardFenceWorks(bwrapPath string) bool { return cmd.Run() == nil } -// Wrap builds the bwrap argv and returns an exec.Cmd. Stdio is inherited so the interactive TUI works; -// on the hard-fence path the child is the supervisor (which execs the agent), on shared net it is the -// agent directly. func (bwrapBackend) Wrap(spec SandboxSpec, argv []string) (*exec.Cmd, error) { if len(argv) == 0 { return nil, fmt.Errorf("sandbox: empty command") @@ -85,7 +77,6 @@ func (bwrapBackend) Wrap(spec SandboxSpec, argv []string) (*exec.Cmd, error) { } full := buildBwrapArgv(spec, self, argv, regularFileExists) - // full[0] is the literal "bwrap"; exec with the resolved path but keep argv[0] as bwrap. // #nosec G204 -- the wrapped command is provided directly by the operator running the CLI cmd := exec.Command(bwrapPath, full[1:]...) cmd.Stdin = os.Stdin diff --git a/packages/sandbox/bwrap_args.go b/packages/sandbox/bwrap_args.go index 1b5c61f6..b996ddd1 100644 --- a/packages/sandbox/bwrap_args.go +++ b/packages/sandbox/bwrap_args.go @@ -1,42 +1,25 @@ package sandbox -// BridgeLoopbackPort is the loopback port the in-namespace bridge listens on (hard-fence path). The -// child's HTTP(S)_PROXY targets 127.0.0.1:this. Because each hard-fence run gets its own empty network -// namespace, this port never collides across concurrent runs, so a fixed value is safe. +// BridgeLoopbackPort is the loopback port the in-namespace bridge listens on. Fixed is safe: each +// hard-fence run has its own empty netns, so it never collides across concurrent runs. const BridgeLoopbackPort = 17321 -// SupervisorSubcommand is the hidden argv token that re-enters this binary as the in-namespace -// supervisor (brings up loopback, starts the TCP->unix bridge, then execs the agent). Exported so the -// cmd package registers a matching hidden cobra command. +// SupervisorSubcommand re-enters this binary as the in-namespace supervisor. Exported so the cmd +// package can register the matching hidden command. const SupervisorSubcommand = "__sandbox-supervisor" -// buildBwrapArgv builds the full argv (starting with "bwrap") for a spec. Pure function (its only -// filesystem dependency is injected via isFile), so it is golden-testable on any platform. +// buildBwrapArgv builds the bwrap argv for a spec. Pure (its filesystem dependency is injected via +// isFile, which reports whether a deny path is a regular file), so it is golden-testable. // -// - selfExe is the path to this binary (/proc/self/exe at call time), re-executed as the supervisor -// on the hard-fence path. -// - argv is the agent command. -// - isFile reports whether a deny path currently exists as a regular file, which decides how it is -// masked (see below). Injected so tests can exercise both branches without touching disk. -// -// Deny paths are masked by their type: a directory (or a path that does not exist) is covered with an -// empty --tmpfs; a regular file is covered by binding /dev/null over it. Using the wrong primitive is -// not cosmetic: bwrap aborts at startup if asked to mount a tmpfs onto a file (ENOTDIR), so a user who -// has e.g. ~/.docker/config.json would otherwise be unable to start the sandbox at all. -// -// Hard fence (NetMode == HardFence): --unshare-all gives an empty network namespace; the proxy's unix -// socket (spec.ProxySocket) is bind-mounted in and the supervisor bridges LoopbackPort -> that socket. -// Shared net (NetMode == SharedNet): --unshare-all --share-net keeps host networking, so the child -// reaches the proxy on host loopback directly and no supervisor/bridge is inserted. -// -// Deliberately omits --new-session (it calls setsid() and breaks the interactive TTY / job control). +// Deny paths are masked by type: /dev/null bind for files, empty tmpfs for directories. This is +// load-bearing, not cosmetic: mounting a tmpfs onto an existing file aborts bwrap at startup (ENOTDIR). +// Omits --new-session (setsid breaks the interactive TTY). On the hard fence the proxy's unix socket +// is bind-mounted and the supervisor bridges to it; shared net reaches host loopback directly. func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string, isFile func(string) bool) []string { args := []string{ "bwrap", "--unshare-all", "--die-with-parent", - // A read-only view of the whole filesystem keeps arbitrary toolchains working; credential paths - // are masked back out below, and the workspace/tempdir are bound writable on top. "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", @@ -47,9 +30,6 @@ func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string, isFile func args = append(args, "--share-net") } - // Mask credential paths (they were visible via --ro-bind / /). A regular file is masked by binding - // /dev/null over it; a directory (or a path that does not exist) is masked with an empty tmpfs. - // tmpfs onto an existing file makes bwrap abort at startup, so the file/dir split is load-bearing. for _, p := range dedupeSorted(spec.DenyPaths) { if isFile(p) { args = append(args, "--ro-bind", "/dev/null", p) @@ -58,7 +38,7 @@ func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string, isFile func } } - // Writable workspace + tempdir, bound AFTER --tmpfs /tmp so a tempdir under /tmp is not shadowed. + // Bound after --tmpfs /tmp so a tempdir under /tmp is not shadowed. if spec.Cwd != "" { args = append(args, "--bind", spec.Cwd, spec.Cwd) } @@ -72,8 +52,6 @@ func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string, isFile func args = append(args, "--") if spec.NetMode == HardFence && spec.ProxySocket != "" { - // Re-exec ourselves as the in-namespace supervisor: it brings loopback up, bridges the child's - // loopback proxy port to the bound unix socket, then execs the agent. args = append(args, selfExe, SupervisorSubcommand, "--port", itoa(spec.LoopbackPort), @@ -85,7 +63,7 @@ func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string, isFile func return append(args, argv...) } -// itoa avoids pulling strconv into the argv builder's tiny surface (keeps it trivially pure). +// itoa keeps this file dependency-free (no strconv). func itoa(n int) string { if n == 0 { return "0" diff --git a/packages/sandbox/sandbox.go b/packages/sandbox/sandbox.go index f8282546..9492b68f 100644 --- a/packages/sandbox/sandbox.go +++ b/packages/sandbox/sandbox.go @@ -9,35 +9,21 @@ import ( var errUnsupportedPlatform = errors.New("sandbox: unsupported platform") -// PreflightResult reports whether the OS sandbox can run here and, on Linux, whether the hard fence -// must fall back to shared networking. type PreflightResult struct { - // Supported is false when no OS sandbox is available (e.g. Windows, or bwrap missing). The caller - // then either errors or, if the user passed --no-sandbox, runs uncontained. - Supported bool - // FallbackToSharedNet is set on Linux when the empty-netns hard fence cannot start (restricted - // unprivileged user namespaces). The caller downgrades NetMode to SharedNet and warns once. - FallbackToSharedNet bool - // UsesBridge is true only on the Linux hard-fence path: the child runs in an empty network - // namespace and reaches the proxy through the in-namespace bridge, so the proxy must listen on a - // pathname unix socket (not TCP) and the child's HTTP(S)_PROXY targets the bridge's loopback port. - // macOS, the Linux shared-net fallback, and --no-sandbox all leave this false (TCP loopback proxy). - UsesBridge bool - // Reason explains an unsupported result or a fallback, suitable for a user-facing message. - Reason string + 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. +// 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 reports whether this backend can run the given spec here, and any required fallback. Preflight(spec SandboxSpec) (PreflightResult, error) - // Wrap returns an *exec.Cmd ready to Start: the target command wrapped by the OS sandbox, with - // stdio inherited and Env set from the spec. It does not Start the process. Wrap(spec SandboxSpec, argv []string) (*exec.Cmd, error) } -// NewBackend returns the OS sandbox backend for the current platform. When spec.Sandbox is false it -// returns the passthrough backend regardless of platform. +// NewBackend returns the platform backend, or the uncontained passthrough backend for --no-sandbox. func NewBackend(spec SandboxSpec) Backend { if !spec.Sandbox { return passthroughBackend{} @@ -45,8 +31,7 @@ func NewBackend(spec SandboxSpec) Backend { return osBackend() } -// passthroughBackend runs the command uncontained (the --no-sandbox path). The ephemeral proxy and -// the scrubbed env still apply; only the OS controls are dropped. +// passthroughBackend runs the command uncontained (--no-sandbox): proxy and scrubbed env still apply. type passthroughBackend struct{} func (passthroughBackend) Preflight(SandboxSpec) (PreflightResult, error) { diff --git a/packages/sandbox/sandbox_unsupported.go b/packages/sandbox/sandbox_unsupported.go index 8e93f7ce..a87260e1 100644 --- a/packages/sandbox/sandbox_unsupported.go +++ b/packages/sandbox/sandbox_unsupported.go @@ -6,9 +6,7 @@ import "os/exec" func osBackend() Backend { return unsupportedBackend{} } -// unsupportedBackend is the OS sandbox on platforms without one (e.g. Windows). Preflight reports -// unsupported so the run command errors unless the user passed --no-sandbox (which selects the -// passthrough backend instead and never reaches here). +// unsupportedBackend is used on platforms without an OS sandbox (e.g. Windows). type unsupportedBackend struct{} func (unsupportedBackend) Preflight(SandboxSpec) (PreflightResult, error) { diff --git a/packages/sandbox/seatbelt.go b/packages/sandbox/seatbelt.go index 184a5c05..5948ce9b 100644 --- a/packages/sandbox/seatbelt.go +++ b/packages/sandbox/seatbelt.go @@ -24,9 +24,7 @@ func (seatbeltBackend) Preflight(SandboxSpec) (PreflightResult, error) { return PreflightResult{Supported: true}, nil } -// Wrap builds: sandbox-exec -p . sandbox-exec takes the command directly (no -- -// separator). The profile is passed inline via -p, never written to disk. Stdio is inherited so the -// interactive TUI works. +// Wrap builds `sandbox-exec -p `; the profile is passed inline, never on disk. func (seatbeltBackend) Wrap(spec SandboxSpec, argv []string) (*exec.Cmd, error) { if len(argv) == 0 { return nil, fmt.Errorf("sandbox: empty command") diff --git a/packages/sandbox/seatbelt_profile.go b/packages/sandbox/seatbelt_profile.go index 62bf435e..e90069c0 100644 --- a/packages/sandbox/seatbelt_profile.go +++ b/packages/sandbox/seatbelt_profile.go @@ -6,21 +6,9 @@ import ( "strings" ) -// generateSeatbeltProfile builds the SBPL (Seatbelt profile language) string for a spec. It is a pure -// function (no OS calls) so it can be golden-tested on any platform. -// -// The profile grants the minimal baseline a process needs to run, with two deliberate deltas for our -// credential boundary: -// -// 1. The keychain mach services (com.apple.securityd.xpc, com.apple.SecurityServer) are OMITTED from -// the allow-list, so (deny default) blocks them and the child cannot read the developer's login -// token from the keyring. We block by omission, never by an explicit deny against a broad allow. -// 2. trustd.agent is likewise omitted; system-root TLS relies on the injected CA bundle instead. -// This is what makes the keychain block TLS-safe: keychain and TLS trust are separate services. -// -// Egress is filtered on (remote ip ...), never (local ip ...): a local filter is evaluated against -// the source address, which for an unbound socket is the any-address, so it would silently admit all -// egress. +// 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 SandboxSpec) string { var b []string add := func(lines ...string) { b = append(b, lines...) } @@ -38,8 +26,7 @@ func generateSeatbeltProfile(spec SandboxSpec) string { "", "(allow user-preference-read)", "", - "; Baseline mach services a process needs to boot, minus the keychain services so the login", - "; token is unreadable, and minus trustd so system-root TLS falls back to the injected CA bundle.", + "; Baseline mach services (keychain services and trustd deliberately omitted)", "(allow mach-lookup", ` (global-name "com.apple.audio.systemsoundserver")`, ` (global-name "com.apple.distributed_notifications@Uv3")`, @@ -81,8 +68,8 @@ func generateSeatbeltProfile(spec SandboxSpec) string { "", ) - // Network. (deny default) already blocks external egress; we allow loopback to the proxy port and - // local binding (so agents can run dev servers). Outbound stays pinned to the proxy port only. + // 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( @@ -92,7 +79,7 @@ func generateSeatbeltProfile(spec SandboxSpec) string { ) add("") - // File read: broad allow, then subtract the credential deny paths (last-match-wins in SBPL). + // 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) { @@ -100,7 +87,6 @@ func generateSeatbeltProfile(spec SandboxSpec) string { } add("") - // File write: cwd, tmp, tempdir, and any operator-granted write paths. add("; File write") writePaths := dedupeSorted(append([]string{spec.Cwd, spec.TempDir, "/tmp", "/private/tmp", "/dev/null"}, spec.WritePaths...)) for _, p := range writePaths { @@ -134,8 +120,7 @@ func dedupeSorted(in []string) []string { return out } -// escapeSBPL quotes a path as an SBPL string literal. SBPL uses C-style double-quoted strings, so -// backslash and double-quote must be escaped. +// escapeSBPL quotes a path as an SBPL (C-style) string literal. func escapeSBPL(p string) string { var sb strings.Builder sb.WriteByte('"') diff --git a/packages/sandbox/spec.go b/packages/sandbox/spec.go index 57651c10..1ef00b2b 100644 --- a/packages/sandbox/spec.go +++ b/packages/sandbox/spec.go @@ -1,67 +1,41 @@ -// Package sandbox builds and applies an OS-level jail around the agent process spawned by -// `infisical secrets agent-proxy run`. It is the trust boundary for local coupled mode: the -// developer's real credentials exist on the same machine, so the sandbox is what stops the -// untrusted agent from reading them. macOS uses Seatbelt (sandbox-exec); Linux uses bubblewrap. -// -// The profile/argv generators are pure functions of a SandboxSpec (no OS calls), so they are -// unit-testable with golden files on any platform. Only the exec wiring is platform-specific. +// 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 SandboxSpec so +// they are golden-testable on any platform; only the exec wiring is platform-specific. package sandbox -import "runtime" - -// NetMode selects how the child reaches the network. type NetMode int const ( - // HardFence is the default: no external egress. On macOS a (deny default) SBPL profile permits - // only loopback to the proxy port; on Linux an empty network namespace is bridged to the proxy's - // unix socket. The proxy is the child's only route out. + // 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. Used on macOS (loopback is already isolated by SBPL) and as - // the Linux fallback when unprivileged user namespaces are restricted (Ubuntu 24.04). It weakens - // only the network fence; the credential controls (env scrub, keyring block, fs deny) are - // unaffected. + // SharedNet: host network shared. macOS (SBPL still fences to loopback) and the Linux fallback + // when unprivileged user namespaces are restricted. Only the network fence weakens, not the + // credential controls. SharedNet ) -// SandboxSpec is the complete, in-memory policy for one wrapped command. Nothing here is persisted. +// SandboxSpec is the in-memory policy for one wrapped command. Nothing here is persisted. type SandboxSpec struct { - // Sandbox=false is the --no-sandbox path: run the child uncontained (proxy + scrubbed env still - // apply). Backends return a plain exec.Cmd in that case. - Sandbox bool + Sandbox bool // false => --no-sandbox: run uncontained - // ReadPaths are extra readable paths beyond the always-included set (cwd, system libraries). - ReadPaths []string - // WritePaths are extra writable paths beyond the always-included set (cwd, tmp). Write implies read. + ReadPaths []string WritePaths []string - // DenyPaths are read-denied even though they fall under a broad read allow: the credential files - // (~/.infisical, ~/.aws, ~/.ssh, ...). On Linux they are simply never bound into the namespace. - DenyPaths []string + DenyPaths []string // read-denied credential paths, subtracted from the broad read allow - // Cwd is the working directory, always readable and writable. - Cwd string - // TempDir is the per-run 0700 scratch dir (CA cert, unix socket, log). Readable inside the box. - TempDir string + Cwd string + TempDir string // per-run 0700 dir: CA cert, and the unix socket on the Linux hard fence - // LoopbackPort is the TCP port the proxy listens on (macOS / Linux shared-net), and the port the - // in-namespace bridge listens on (Linux hard fence). The only permitted egress port on macOS. + // 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 is the pathname unix socket the proxy listens on (Linux hard fence only). When set, - // the bridge forwards LoopbackPort -> this socket. - ProxySocket string - - // Env is the fully-prepared child environment (already scrubbed; proxy + CA + placeholders). - Env []string - - // AllowHosts are extra hostnames the agent may reach; informational for the sandbox layer (host - // policy is enforced by the proxy), carried here for completeness. - AllowHosts []string + ProxySocket string + Env []string // fully-prepared, scrubbed child environment NetMode NetMode } -// DefaultDenyPaths returns the credential paths denied by default, resolved against the given home -// directory. Kept here (not in a backend) so both OS backends and the tests agree on the set. +// DefaultDenyPaths returns the credential paths denied by default, resolved against home. func DefaultDenyPaths(home string) []string { if home == "" { return nil @@ -83,6 +57,3 @@ func DefaultDenyPaths(home string) []string { } return paths } - -// CurrentOS reports the platform the process is running on. Split out so tests can reason about it. -func CurrentOS() string { return runtime.GOOS } From eb2388400ae65d6fab2c9e5c2ca0c392a1554860 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:35:07 +0530 Subject: [PATCH 06/13] fix(agent-proxy): keep run's proxy activity logs off the interactive terminal `run` wraps an interactive agent that owns the terminal, but it inherited the engine's per-request activity logging (brokered/blocked/passthrough/error), which interleaved with the agent's output. Route the engine's ongoing zerolog output to a file instead: `--log-file` picks a stable path, otherwise a per-run temp file (removed on exit; tail it live to watch brokering). One-time startup notices and lifecycle errors still go to stderr, and HandleError/PrintWarning are unaffected (they write to stderr independently). This is the deliberate difference from `start`, whose activity log IS its output and streams to console. --- packages/cmd/agent_proxy_run.go | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go index 6abb19eb..309977d6 100644 --- a/packages/cmd/agent_proxy_run.go +++ b/packages/cmd/agent_proxy_run.go @@ -115,6 +115,22 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { cleanup := func() { _ = os.RemoveAll(tempDir) } fail := func(e error, messages ...string) { cleanup(); util.HandleError(e, messages...) } + // Unlike `start` (a daemon whose activity log IS its output), `run` wraps an interactive agent that + // owns the terminal. Route the proxy's per-request activity and poll logs to a file so they don't + // interleave with the agent's output. --log-file picks a stable path (survives teardown); the + // default lives in the per-run tempdir and is removed on exit (tail it live to watch brokering). + // HandleError/PrintWarning write to stderr independently, so errors and one-time notices still show. + logFile, _ := cmd.Flags().GetString("log-file") + logPath := logFile + if logPath == "" { + logPath = filepath.Join(tempDir, "agent-proxy.log") + } + logF, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + fail(err, "Failed to open the proxy log file") + } + log.Logger = log.Output(GetLoggerConfig(logF, true)) + home, _ := os.UserHomeDir() cwd, _ := os.Getwd() extraRead, _ := cmd.Flags().GetStringArray("allow-read") @@ -194,8 +210,9 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { if !sandboxEnabled { util.PrintWarning("running WITHOUT the OS sandbox (--no-sandbox): the agent is uncontained and can read your keyring, credential files, and reach the network directly. Secrets are still brokered on the wire, but the sandbox boundary is off.") } else { - log.Info().Msg(color.HiBlackString("Local coupled mode: the proxy acts with your own access and the OS sandbox is the boundary that keeps the agent from reading your credentials directly.")) + fmt.Fprintln(os.Stderr, color.HiBlackString("Local coupled mode: the proxy acts with your own access and the OS sandbox is the boundary that keeps the agent from reading your credentials directly.")) } + fmt.Fprintln(os.Stderr, color.HiBlackString("proxy activity log: "+logPath)) child, err := backend.Wrap(spec, args) if err != nil { @@ -212,10 +229,10 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { // 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 { - log.Info().Msg(color.GreenString("Starting agent behind the Infisical agent proxy (sandboxed)")) + fmt.Fprintln(os.Stderr, color.GreenString("Starting agent behind the Infisical agent proxy (sandboxed)")) if err := child.Start(); err != nil { - log.Error().Err(err).Msg("failed to start the agent process") + fmt.Fprintf(os.Stderr, "failed to start the agent process: %v\n", err) return 1 } @@ -234,7 +251,7 @@ func runSandboxedChild(child *exec.Cmd, proxy *agentproxy.Proxy, proxyErrCh <-ch select { case err := <-proxyErrCh: - log.Error().Err(err).Msg("the ephemeral proxy stopped unexpectedly; terminating the agent") + 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() } @@ -255,7 +272,7 @@ func exitCodeFromWait(err error) int { return ws.ExitStatus() } } - log.Error().Err(err).Msg("agent process error") + fmt.Fprintf(os.Stderr, "agent process error: %v\n", err) return 1 } @@ -497,6 +514,7 @@ func init() { agentProxyRunCmd.Flags().Bool("no-sandbox", false, "disable the OS sandbox (agent runs uncontained; prints a warning)") agentProxyRunCmd.Flags().String("unmatched-host", "allow", "policy for hosts with no proxied service: allow | block") agentProxyRunCmd.Flags().Int("poll-interval", 60, "seconds between permission/credential refreshes") + agentProxyRunCmd.Flags().String("log-file", "", "write the proxy activity log to this path (default: a per-run temp file, kept off the terminal)") agentProxyRunCmd.Flags().StringArray("allow-read", nil, "extra path the agent may read (repeatable)") agentProxyRunCmd.Flags().StringArray("allow-write", nil, "extra path the agent may write (repeatable; implies read)") agentProxyRunCmd.Flags().StringArray("allow-host", nil, "extra host the agent may reach through the proxy (repeatable)") From 42a3453095d1edae167c44cf3174dae2cc58a807 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:16:29 +0530 Subject: [PATCH 07/13] feat(agent-proxy): broker native-trust tools on macOS via a persistent trusted CA macOS Go CLIs (e.g. gh) verify TLS through Security.framework, which ignores the injected CA env var, so they couldn't be brokered. Fix it without weakening the credential boundary by using a persistent local root that's trusted once in the keychain, and by allowing only the cert-evaluation service in the sandbox. - agentproxy: add a persistent local root (newPersistentLocalCaManager) stored under a caller-chosen dir (load-or-create, self-heals when missing/corrupt/near expiry, key written 0600, atomic writes, flock so concurrent first-runs don't race). LocalOptions.CADir selects it; empty keeps the ephemeral in-memory root. - sandbox: SandboxSpec.AllowTrustd gates the trustd (cert-trust) service in the SBPL profile. securityd/SecurityServer (keychain secret reads) stay omitted regardless, so the login token remains unreadable in the box. - cmd: on macOS, persist the root under ~/.infisical/agent-proxy (already a sandbox-denied path, so the agent can't read the key), trust it once in the login keychain (one-time prompt; self-heals on removal), and set AllowTrustd. Trust-install is non-fatal: env-CA tools (Claude Code, Codex, curl) work regardless. Linux keeps the ephemeral root + SSL_CERT_FILE (no keychain). Verified: persistent-CA lifecycle (reuse/heal/mint) and the trustd toggle (trustd allowed, keychain services still omitted) via unit/golden tests; the trustd-allowed / securityd-blocked split (Go TLS verifies, keychain read fails) empirically on macOS. NOT yet verified end-to-end: the keychain trust-install (needs the interactive prompt) and gh brokering through a live proxy. --- packages/agentproxy/ca.go | 29 ++-- packages/agentproxy/local.go | 5 + packages/agentproxy/local_ca_store.go | 138 +++++++++++++++++++ packages/agentproxy/local_ca_store_test.go | 64 +++++++++ packages/agentproxy/proxy.go | 8 +- packages/cmd/agent_proxy_run.go | 23 ++++ packages/cmd/agent_proxy_run_trust_darwin.go | 43 ++++++ packages/cmd/agent_proxy_run_trust_other.go | 7 + packages/sandbox/seatbelt_profile.go | 12 +- packages/sandbox/seatbelt_profile_test.go | 21 +++ packages/sandbox/spec.go | 5 + 11 files changed, 345 insertions(+), 10 deletions(-) create mode 100644 packages/agentproxy/local_ca_store.go create mode 100644 packages/agentproxy/local_ca_store_test.go create mode 100644 packages/cmd/agent_proxy_run_trust_darwin.go create mode 100644 packages/cmd/agent_proxy_run_trust_other.go diff --git a/packages/agentproxy/ca.go b/packages/agentproxy/ca.go index 3a56b3f0..8a79e146 100644 --- a/packages/agentproxy/ca.go +++ b/packages/agentproxy/ca.go @@ -61,16 +61,15 @@ func newCaManager(token func() string) *caManager { } } -// newLocalCaManager builds an in-memory self-signed ECDSA P-256 root (P-256 so rustls-based agents -// accept it) that mintLeaf signs leaves from directly. The private key never leaves memory. -func newLocalCaManager() (*caManager, 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, fmt.Errorf("failed to generate local root CA key: %w", err) + 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, err + return nil, nil, err } template := &x509.Certificate{ SerialNumber: serial, @@ -84,19 +83,33 @@ func newLocalCaManager() (*caManager, error) { } der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) if err != nil { - return nil, fmt.Errorf("failed to self-sign local root CA: %w", err) + return nil, nil, fmt.Errorf("failed to self-sign local root CA: %w", err) } cert, err := x509.ParseCertificate(der) if err != nil { - return nil, fmt.Errorf("failed to parse local root CA certificate: %w", err) + 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, intermediateKey: key, intermediateCert: cert, intermediateExp: cert.NotAfter, leafCache: make(map[string]*leafEntry), - }, nil + } +} + +// 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). diff --git a/packages/agentproxy/local.go b/packages/agentproxy/local.go index 08d164d0..8db544f0 100644 --- a/packages/agentproxy/local.go +++ b/packages/agentproxy/local.go @@ -18,6 +18,11 @@ type LocalOptions struct { // UserToken returns the developer's current access token; called per request so it can rotate. UserToken func() string + // CADir, if set, persists the local root CA there (reused across runs and trustable in the OS + // trust store) instead of a fresh in-memory root. Must be a sandbox-denied path so the agent + // cannot read the key. + CADir string + // InfisicalHost (bare hostname) is always refused through the proxy, keeping the control plane // unreachable from the sandbox as an invariant, not just because the child holds no token. InfisicalHost string diff --git a/packages/agentproxy/local_ca_store.go b/packages/agentproxy/local_ca_store.go new file mode 100644 index 00000000..24116546 --- /dev/null +++ b/packages/agentproxy/local_ca_store.go @@ -0,0 +1,138 @@ +package agentproxy + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/sys/unix" +) + +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, or generates and writes one if it is +// missing, unparseable, or within localCARenewMargin of expiry (self-heal). The key stays on disk so +// the same root is reused across runs (and can be trusted once in the OS trust store). dir must be a +// path the sandbox denies, so the agent cannot read the key. +// +// A file lock serializes concurrent first-runs so two processes don't both generate/write. +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, or near expiry (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 + } + 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 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 flock on a lockfile in dir; the returned func releases it. +func lockDir(dir string) (func(), error) { + f, err := os.OpenFile(filepath.Join(dir, ".ca.lock"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("failed to open CA lock: %w", err) + } + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil { + f.Close() + return nil, fmt.Errorf("failed to lock CA dir: %w", err) + } + return func() { + _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) + _ = f.Close() + }, 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..aa06cb36 --- /dev/null +++ b/packages/agentproxy/local_ca_store_test.go @@ -0,0 +1,64 @@ +package agentproxy + +import ( + "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") + } +} + +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/proxy.go b/packages/agentproxy/proxy.go index 1371e60b..ffd9745e 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -110,7 +110,13 @@ func newProxyServer(opts Options) (*proxyServer, error) { var ca *caManager var cache serviceResolver if opts.Local != nil { - localCa, err := newLocalCaManager() + 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 } diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go index 309977d6..830e54c3 100644 --- a/packages/cmd/agent_proxy_run.go +++ b/packages/cmd/agent_proxy_run.go @@ -11,6 +11,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" "strings" "syscall" "time" @@ -137,6 +138,13 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { extraWrite, _ := cmd.Flags().GetStringArray("allow-write") allowHosts, _ := cmd.Flags().GetStringArray("allow-host") + // On macOS, persist the local root under ~/.infisical (already sandbox-denied, so the agent can't + // read the key) and trust it once in the keychain, so native-trust tools (Go CLIs like gh) can be + // brokered too. Elsewhere the injected CA env var is enough, so we keep an ephemeral in-memory root. + if runtime.GOOS == "darwin" && home != "" { + local.CADir = filepath.Join(home, ".infisical", "agent-proxy") + } + // Supported agents persist their own state under home; make those dirs writable so interactive // sessions can save. It's the agent's own data, not the developer's secrets. writePaths := append(defaultAgentStateWritePaths(home), extraWrite...) @@ -177,6 +185,21 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { fail(err, "Failed to initialize the ephemeral agent proxy") } + // macOS: trust the persistent root once so native-trust tools (gh) accept the proxy's certs, and + // allow trustd in the profile for the sandbox. securityd stays blocked, so the token stays + // unreadable. Trust-install is non-fatal: env-CA tools (Claude Code, Codex, curl) work regardless. + if local.CADir != "" { + if sandboxEnabled { + spec.AllowTrustd = true + } + switch installed, terr := ensureCATrusted(agentproxy.LocalCACertPath(local.CADir)); { + case terr != nil: + util.PrintWarning(fmt.Sprintf("could not trust the local CA in your keychain (%v); Go-native tools like gh won't be brokered, but Claude Code, Codex, and curl still will", terr)) + case installed: + util.PrintWarning("added the Infisical agent-proxy local CA to your login keychain (one-time) so native-trust tools can be brokered; it 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 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..af17e86c --- /dev/null +++ b/packages/cmd/agent_proxy_run_trust_darwin.go @@ -0,0 +1,43 @@ +//go:build darwin + +package cmd + +import ( + "os/exec" +) + +// ensureCATrusted makes sure the local root at certPath is a trusted anchor in the login keychain, so +// native-trust clients (Go CLIs like gh) accept the proxy's leaves. It is silent when the cert is +// already trusted; installing (first run, or after removal/expiry) triggers a one-time macOS password +// prompt. Returns (installed, error): installed=true means an anchor was just added. +// +// This only affects cert TRUST. It never touches keychain secrets: securityd stays blocked in the +// sandbox, so the agent still cannot read the login token. +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/sandbox/seatbelt_profile.go b/packages/sandbox/seatbelt_profile.go index e90069c0..82199031 100644 --- a/packages/sandbox/seatbelt_profile.go +++ b/packages/sandbox/seatbelt_profile.go @@ -26,7 +26,7 @@ func generateSeatbeltProfile(spec SandboxSpec) string { "", "(allow user-preference-read)", "", - "; Baseline mach services (keychain services and trustd deliberately omitted)", + "; Baseline mach services (keychain services deliberately omitted; trustd gated on AllowTrustd)", "(allow mach-lookup", ` (global-name "com.apple.audio.systemsoundserver")`, ` (global-name "com.apple.distributed_notifications@Uv3")`, @@ -68,6 +68,16 @@ func generateSeatbeltProfile(spec SandboxSpec) string { "", ) + 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") diff --git a/packages/sandbox/seatbelt_profile_test.go b/packages/sandbox/seatbelt_profile_test.go index 79a4e662..66c883fe 100644 --- a/packages/sandbox/seatbelt_profile_test.go +++ b/packages/sandbox/seatbelt_profile_test.go @@ -63,6 +63,27 @@ func TestSeatbeltProfileStructure(t *testing.T) { } } +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`} diff --git a/packages/sandbox/spec.go b/packages/sandbox/spec.go index 1ef00b2b..425126a0 100644 --- a/packages/sandbox/spec.go +++ b/packages/sandbox/spec.go @@ -33,6 +33,11 @@ type SandboxSpec struct { Env []string // fully-prepared, scrubbed child environment NetMode NetMode + + // AllowTrustd allows the macOS cert-trust evaluation service so native-trust tools (Go CLIs like + // gh) can verify the proxy's leaves against a CA trusted in the keychain. securityd (keychain + // secret reads) stays blocked regardless, so the login token remains unreadable. macOS only. + AllowTrustd bool } // DefaultDenyPaths returns the credential paths denied by default, resolved against home. From 97a1a93537d685bad8fcd00453521486328f3294 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:26:45 +0530 Subject: [PATCH 08/13] fix(agent-proxy): harden Linux sandbox credential masking Found by running the bwrap path on real Ubuntu 22.04/24.04 (never exercised on hardware before): - Skip deny paths that don't exist. Masking a missing path with --tmpfs/--ro-bind aborted bwrap ("Can't mkdir ...: Read-only file system") because the mountpoint can't be created under the read-only root bind, so every run on a fresh account died before the agent started. - Emit deny mounts after the cwd/tempdir/write binds so they always win. A cwd bind that is an ancestor of a deny path (e.g. launching the agent from $HOME) previously re-exposed ~/.aws, ~/.ssh, etc. - Deny ~/infisical-keyring, the file-vault credential store that is the default on headless Linux; it held the login JWT and backup key and was readable from inside the sandbox. - Preflight: when unprivileged user namespaces are fully restricted (e.g. Ubuntu 24.04 AppArmor), report a clear, actionable error instead of falling back to shared-net and dying with a raw "bwrap: setting up uid map: Permission denied". --- packages/sandbox/bwrap.go | 44 ++++++++++++++++--- packages/sandbox/bwrap_args.go | 35 ++++++++++----- packages/sandbox/bwrap_args_test.go | 66 +++++++++++++++++++++++++---- packages/sandbox/spec.go | 1 + 4 files changed, 120 insertions(+), 26 deletions(-) diff --git a/packages/sandbox/bwrap.go b/packages/sandbox/bwrap.go index 5f9c6200..02e6448e 100644 --- a/packages/sandbox/bwrap.go +++ b/packages/sandbox/bwrap.go @@ -10,9 +10,15 @@ import ( func osBackend() Backend { return bwrapBackend{} } -func regularFileExists(path string) bool { +// statDenyPath reports whether a deny path exists and, if so, whether it is a regular file. A missing +// path is skipped by buildBwrapArgv (nothing to hide, and the mountpoint can't be created under the +// read-only root bind). +func statDenyPath(path string) (exists, isFile bool) { info, err := os.Stat(path) - return err == nil && info.Mode().IsRegular() + if err != nil { + return false, false + } + return true, info.Mode().IsRegular() } type bwrapBackend struct{} @@ -37,16 +43,42 @@ func (bwrapBackend) Preflight(spec SandboxSpec) (PreflightResult, error) { return PreflightResult{Supported: true, UsesBridge: true}, nil } - // Empty-netns hard fence unavailable (most commonly the Ubuntu 24.04 userns restriction). Fall back - // to shared host networking. Credential controls are unaffected; only the network fence weakens. + // The hard fence probe failed. Before downgrading to shared net, check whether bwrap can create a + // user namespace at all: if it cannot (e.g. Ubuntu 24.04 with unprivileged userns fully restricted), + // the shared-net fallback would also die at "setting up uid map", so falling back would only trade a + // clear error for a raw bwrap crash. In that case report unsupported with actionable guidance. + if !sharedNetWorks(bwrapPath) { + return PreflightResult{ + Supported: false, + Reason: "the OS sandbox cannot start: unprivileged user namespaces are restricted on this host " + + "(e.g. Ubuntu 24.04 AppArmor). Allow them with `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0` " + + "(or install an AppArmor profile permitting bwrap user namespaces), or re-run with --no-sandbox to skip the 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: "unprivileged user namespaces appear restricted (e.g. Ubuntu 24.04 AppArmor). To restore the hard fence, install an AppArmor profile allowing bwrap userns or set kernel.apparmor_restrict_unprivileged_userns=0", + Reason: "the empty-netns hard fence is unavailable on this host. To restore it, install an AppArmor profile allowing bwrap user namespaces or set kernel.apparmor_restrict_unprivileged_userns=0", }, nil } +// sharedNetWorks probes whether bwrap can start a user-namespaced sandbox that shares host networking. +// It mirrors the shared-net fallback's own bwrap flags, so a success here means the fallback can run. +func sharedNetWorks(bwrapPath string) bool { + // #nosec G204 -- fixed argv, no user input + cmd := exec.Command(bwrapPath, + "--unshare-all", "--share-net", "--die-with-parent", + "--ro-bind", "/", "/", + "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", + "--", "true", + ) + return cmd.Run() == nil +} + // hardFenceWorks probes whether an empty-netns bwrap can start and bring loopback up. func hardFenceWorks(bwrapPath string) bool { self, err := os.Executable() @@ -76,7 +108,7 @@ func (bwrapBackend) Wrap(spec SandboxSpec, argv []string) (*exec.Cmd, error) { return nil, fmt.Errorf("cannot resolve own executable for the sandbox supervisor: %w", err) } - full := buildBwrapArgv(spec, self, argv, regularFileExists) + full := buildBwrapArgv(spec, self, argv, statDenyPath) // #nosec G204 -- the wrapped command is provided directly by the operator running the CLI cmd := exec.Command(bwrapPath, full[1:]...) cmd.Stdin = os.Stdin diff --git a/packages/sandbox/bwrap_args.go b/packages/sandbox/bwrap_args.go index b996ddd1..89afbb56 100644 --- a/packages/sandbox/bwrap_args.go +++ b/packages/sandbox/bwrap_args.go @@ -9,13 +9,19 @@ const BridgeLoopbackPort = 17321 const SupervisorSubcommand = "__sandbox-supervisor" // buildBwrapArgv builds the bwrap argv for a spec. Pure (its filesystem dependency is injected via -// isFile, which reports whether a deny path is a regular file), so it is golden-testable. +// classify, which reports whether a deny path exists and, if so, whether it is a regular file), so it +// is golden-testable. // // Deny paths are masked by type: /dev/null bind for files, empty tmpfs for directories. This is // load-bearing, not cosmetic: mounting a tmpfs onto an existing file aborts bwrap at startup (ENOTDIR). +// A deny path that does not exist is skipped entirely: there is no credential there to hide, and bwrap +// cannot create the mountpoint under the read-only root bind, so masking a missing path would abort the +// whole sandbox at startup ("Can't mkdir ...: Read-only file system"). +// Deny mounts are emitted after the cwd/tempdir/write binds so they always win: otherwise a cwd bind +// that is an ancestor of a deny path (running the agent from $HOME) would re-expose ~/.aws, ~/.ssh, etc. // Omits --new-session (setsid breaks the interactive TTY). On the hard fence the proxy's unix socket // is bind-mounted and the supervisor bridges to it; shared net reaches host loopback directly. -func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string, isFile func(string) bool) []string { +func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string, classify func(string) (exists, isFile bool)) []string { args := []string{ "bwrap", "--unshare-all", @@ -30,15 +36,7 @@ func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string, isFile func args = append(args, "--share-net") } - for _, p := range dedupeSorted(spec.DenyPaths) { - if isFile(p) { - args = append(args, "--ro-bind", "/dev/null", p) - } else { - args = append(args, "--tmpfs", p) - } - } - - // Bound after --tmpfs /tmp so a tempdir under /tmp is not shadowed. + // Read/write binds first. Bound after --tmpfs /tmp so a tempdir under /tmp is not shadowed. if spec.Cwd != "" { args = append(args, "--bind", spec.Cwd, spec.Cwd) } @@ -49,6 +47,21 @@ func buildBwrapArgv(spec SandboxSpec, selfExe string, argv []string, isFile func args = append(args, "--bind", p, p) } + // Deny mounts LAST so they always win: a cwd or write bind that is an ancestor of a deny path + // (e.g. running the agent from $HOME, whose bind would otherwise re-expose ~/.aws, ~/.ssh, ...) + // must not be able to re-expose a credential path masked earlier. + for _, p := range dedupeSorted(spec.DenyPaths) { + exists, isFile := classify(p) + if !exists { + continue + } + if isFile { + args = append(args, "--ro-bind", "/dev/null", p) + } else { + args = append(args, "--tmpfs", p) + } + } + args = append(args, "--") if spec.NetMode == HardFence && spec.ProxySocket != "" { diff --git a/packages/sandbox/bwrap_args_test.go b/packages/sandbox/bwrap_args_test.go index 721b31b3..48c173ba 100644 --- a/packages/sandbox/bwrap_args_test.go +++ b/packages/sandbox/bwrap_args_test.go @@ -18,9 +18,10 @@ func bwrapSpec(net NetMode) SandboxSpec { } } -// allDirs classifies every deny path as a directory (never a file), so the default specs mask with -// --tmpfs. Tests that care about the file branch pass their own classifier. -func allDirs(string) bool { return false } +// 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 } // argIndex returns the index of the first occurrence of tok, or -1. func argIndex(args []string, tok string) int { @@ -33,7 +34,7 @@ func argIndex(args []string, tok string) int { } func TestBwrapArgvHardFence(t *testing.T) { - args := buildBwrapArgv(bwrapSpec(HardFence), "/proc/self/exe", []string{"claude", "--flag"}, allDirs) + args := buildBwrapArgv(bwrapSpec(HardFence), "/proc/self/exe", []string{"claude", "--flag"}, allExistingDirs) joined := strings.Join(args, " ") for _, want := range []string{ @@ -74,7 +75,7 @@ func TestBwrapArgvHardFence(t *testing.T) { } func TestBwrapArgvSharedNet(t *testing.T) { - args := buildBwrapArgv(bwrapSpec(SharedNet), "/proc/self/exe", []string{"claude"}, allDirs) + args := buildBwrapArgv(bwrapSpec(SharedNet), "/proc/self/exe", []string{"claude"}, allExistingDirs) joined := strings.Join(args, " ") if !strings.Contains(joined, "--share-net") { @@ -103,11 +104,11 @@ func lastArgPairIndex(args []string, flag, val string) int { 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. - isFile := func(p string) bool { - return p == "/home/dev/.docker/config.json" || p == "/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"}, isFile), " ") + 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{ @@ -125,6 +126,53 @@ func TestBwrapArgvMasksFilesWithDevNull(t *testing.T) { } } +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 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) + } + } +} + func TestItoa(t *testing.T) { cases := map[int]string{0: "0", 7: "7", 17321: "17321", -42: "-42"} for in, want := range cases { diff --git a/packages/sandbox/spec.go b/packages/sandbox/spec.go index 425126a0..f0994ef4 100644 --- a/packages/sandbox/spec.go +++ b/packages/sandbox/spec.go @@ -47,6 +47,7 @@ func DefaultDenyPaths(home string) []string { } rel := []string{ ".infisical", + "infisical-keyring", // file-vault backend store (JWT + backup key); default on headless Linux ".aws", ".ssh", ".config/gcloud", From 12add4c4e6cfacf399f6f4d33cab903fe6bd87fd Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:26:55 +0530 Subject: [PATCH 09/13] fix(agent-proxy): fix hard-fence supervisor loopback and signal forwarding - bringLoopbackUp: bwrap already brings lo up in the new netns, and the SIOCSIFFLAGS write is refused with EPERM there even with CAP_NET_ADMIN. Treating that as fatal made the hard fence fall back to the weaker shared-net path on every Linux host. Treat an already-up lo as success so the empty-netns fence actually engages. - Forward only SIGINT/SIGTERM/SIGHUP/SIGQUIT to the sandboxed child rather than every signal. signal.Notify with no filter also relayed SIGURG (the Go runtime's async-preemption signal) and SIGCHLD, which raced the child's exit path and intermittently corrupted a clean exit 0 into 255 (and occasionally killed the child mid-run). Terminal-generated signals still reach the child directly through the shared controlling-terminal foreground group. Same fix applied to the non-sandboxed and remote agent-proxy paths. --- packages/cmd/agent_proxy.go | 5 ++++- packages/cmd/agent_proxy_run.go | 7 ++++++- packages/sandbox/bridge.go | 25 ++++++++++++++++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index a8fefb01..d1a32142 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -411,8 +411,11 @@ func runAgentProcess(args, env []string) error { proc.Stderr = os.Stderr proc.Env = env + // Forward only process-directed termination signals; notifying on ALL signals also delivers SIGURG + // (Go's async-preemption signal) and SIGCHLD, which then get spammed at the child and can corrupt + // its exit status. Terminal-generated signals reach the child directly via the foreground pgroup. sigChannel := make(chan os.Signal, 1) - signal.Notify(sigChannel) + signal.Notify(sigChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) if err := proc.Start(); err != nil { return err diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go index 830e54c3..8f7e71a4 100644 --- a/packages/cmd/agent_proxy_run.go +++ b/packages/cmd/agent_proxy_run.go @@ -259,8 +259,13 @@ func runSandboxedChild(child *exec.Cmd, proxy *agentproxy.Proxy, proxyErrCh <-ch return 1 } + // Forward only process-directed termination signals. Notifying on ALL signals (signal.Notify with + // no filter) also delivers SIGURG (the Go runtime's async-preemption signal, fired constantly) and + // SIGCHLD, which we would then spam at the child and race into its exit path, intermittently + // corrupting its exit status into 255. Terminal-generated signals (Ctrl-C, SIGWINCH, Ctrl-Z) reach + // the child directly: it shares the controlling terminal's foreground process group. sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) go func() { for sig := range sigCh { if child.Process != nil { diff --git a/packages/sandbox/bridge.go b/packages/sandbox/bridge.go index 3c317c6b..ee13cd31 100644 --- a/packages/sandbox/bridge.go +++ b/packages/sandbox/bridge.go @@ -55,8 +55,12 @@ func RunSupervisor(probe bool, port int, socket string, argv []string) int { return 1 } + // Forward only process-directed termination signals. Notifying on ALL signals also delivers SIGURG + // (the Go runtime's async-preemption signal) and SIGCHLD, which would be spammed at the agent and + // race its exit path, intermittently corrupting its exit status into 255. Terminal-generated + // signals reach the agent directly through the shared controlling-terminal foreground group. sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) go func() { for sig := range sigCh { if agent.Process != nil { @@ -107,7 +111,12 @@ func bridgeConn(client net.Conn, socket string) { <-done } -// bringLoopbackUp brings lo UP; an empty netns starts with it DOWN, so 127.0.0.1 is dead until then. +// bringLoopbackUp ensures lo is UP so 127.0.0.1 is reachable inside the netns. bwrap already brings +// loopback up when it creates the new network namespace, so in practice lo is UP by the time we get +// here; we still check and, only if it is down, try to raise it. The SIOCSIFFLAGS write is refused +// with EPERM in bwrap's user-namespaced netns even with CAP_NET_ADMIN, so treating that failure as +// fatal would force every Linux run to fall back to the weaker shared-net path. We therefore return +// success whenever lo is already UP, and surface the error only when it is genuinely still down. func bringLoopbackUp() error { fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0) if err != nil { @@ -122,7 +131,17 @@ func bringLoopbackUp() error { 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) - return unix.IoctlIfreq(fd, unix.SIOCSIFFLAGS, ifr) + 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 } From c1434e173ebcb1d3be51c9862d714c3591672141 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:33:34 +0530 Subject: [PATCH 10/13] fix(agent-proxy): quiet `agent-proxy run` startup output - Drop the "Local coupled mode" notice: internal jargon, not actionable. - Replace the routine token-expiry warning with a fail-fast error only when the token is already expired; no "expires in Xh" banner on every run. Matches auth-bearing CLIs (kubectl, cloud CLIs) that surface an error at use rather than pre-warning. - Only print the proxy activity-log path when --log-file pins it to a stable location; the default log lives in the per-run tempdir and is removed on exit, so printing its path was useless. --- packages/cmd/agent_proxy_run.go | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go index 8f7e71a4..da3250b5 100644 --- a/packages/cmd/agent_proxy_run.go +++ b/packages/cmd/agent_proxy_run.go @@ -232,10 +232,12 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { if !sandboxEnabled { util.PrintWarning("running WITHOUT the OS sandbox (--no-sandbox): the agent is uncontained and can read your keyring, credential files, and reach the network directly. Secrets are still brokered on the wire, but the sandbox boundary is off.") - } else { - fmt.Fprintln(os.Stderr, color.HiBlackString("Local coupled mode: the proxy acts with your own access and the OS sandbox is the boundary that keeps the agent from reading your credentials directly.")) } - fmt.Fprintln(os.Stderr, color.HiBlackString("proxy activity log: "+logPath)) + // The default log lives in the per-run tempdir and is removed on exit, so its path is only worth + // surfacing when --log-file pins it to a stable location. + if logFile != "" { + fmt.Fprintln(os.Stderr, color.HiBlackString("proxy activity log: "+logPath)) + } child, err := backend.Wrap(spec, args) if err != nil { @@ -336,10 +338,11 @@ type tokenSource struct { // resolveDeveloperTokenSource resolves the run's identity: an explicit --token/env, else the keyring // login. No machine-identity path (that's for services, not a person on a laptop). Neither can be -// refreshed mid-session, so warnTokenExpiry flags when brokering will stop. +// refreshed mid-session; we fail fast if the token is already expired but otherwise stay quiet, like +// other auth-bearing CLIs (kubectl, cloud CLIs) that surface an error at use rather than pre-warning. func resolveDeveloperTokenSource(cmd *cobra.Command) tokenSource { if token, err := util.GetInfisicalToken(cmd); err == nil && token != nil && token.Token != "" { - warnTokenExpiry(token.Token, "the provided token") + failIfTokenExpired(token.Token, "the provided token") return tokenSource{token: func() string { return token.Token }, label: "token"} } @@ -351,23 +354,16 @@ func resolveDeveloperTokenSource(cmd *cobra.Command) tokenSource { util.HandleError(fmt.Errorf("could not resolve your Infisical login; run 'infisical login' or pass --token")) } jwt := details.UserCredentials.JTWToken - warnTokenExpiry(jwt, "your login") + failIfTokenExpired(jwt, "your login") return tokenSource{token: func() string { return jwt }, label: details.UserCredentials.Email} } -// warnTokenExpiry prints when brokering will stop; silent when the expiry can't be read. -func warnTokenExpiry(jwtToken, subject string) { - exp, ok := jwtExpiry(jwtToken) - if !ok { - return - } - remaining := time.Until(exp) - if remaining <= 0 { - util.PrintWarning(fmt.Sprintf("%s has expired; brokering will fail. Run 'infisical login' or pass a valid --token.", subject)) - return +// 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)) } - util.PrintWarning(fmt.Sprintf("%s expires in %s (at %s); brokering stops then and cannot be refreshed mid-session. For a longer run, start fresh after 'infisical login'.", - subject, remaining.Round(time.Minute), exp.Local().Format("15:04"))) } // jwtExpiry reads the exp claim unverified; false if the token isn't a readable JWT (e.g. a service token). From 0b76f4938d596b1bb08df25338a8cb8a05dfde19 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:45:29 +0530 Subject: [PATCH 11/13] fix(agent-proxy): fail closed if the control-plane host can't be derived infisicalAPIHost() used the lenient url.Parse, so a scheme-less --domain (e.g. "app.infisical.com/api") yielded an empty host and the control-plane egress fence silently turned off. Parse strictly with url.ParseRequestURI (the pattern user.go already uses) and abort the run with a clear error when the host can't be determined, rather than starting with the fence disabled. --- packages/cmd/agent_proxy_run.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go index da3250b5..5d40194e 100644 --- a/packages/cmd/agent_proxy_run.go +++ b/packages/cmd/agent_proxy_run.go @@ -97,12 +97,19 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { httpClient := resty.New().SetAuthToken(src.token()) placeholders := fetchLocalProxiedServiceConfig(httpClient, projectID, environment, secretPath) + // The control-plane fence keys off this host, so refuse to start if we can't derive it rather than + // silently running with the fence disabled. + apiHost := infisicalAPIHost() + if apiHost == "" { + util.HandleError(fmt.Errorf("could not determine the Infisical API host from domain %q; pass a full URL via --domain (e.g. https://app.infisical.com)", config.INFISICAL_URL)) + } + local := &agentproxy.LocalOptions{ ProjectID: projectID, Environment: environment, SecretPath: secretPath, UserToken: src.token, - InfisicalHost: infisicalAPIHost(), + InfisicalHost: apiHost, IdentityID: src.label, IdentityName: src.label, } @@ -437,9 +444,11 @@ func localProxyURL(proxyAddr string) string { return u.String() } -// infisicalAPIHost is the bare hostname of the configured Infisical API (the proxy refuses egress to it). +// infisicalAPIHost is the bare hostname of the configured Infisical API (the proxy refuses egress to +// it). ParseRequestURI (not the lenient url.Parse) so a scheme-less domain yields "" and the caller +// fails closed instead of running with the control-plane fence silently disabled. func infisicalAPIHost() string { - u, err := url.Parse(config.INFISICAL_URL) + u, err := url.ParseRequestURI(config.INFISICAL_URL) if err != nil { return "" } From 917e4f71524957347bdf7ddcda38bb4ec72c4b60 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:19:02 +0530 Subject: [PATCH 12/13] improvement(agent-proxy): resolve run --env/--path like connect Use util.ResolveEnvironmentName/ResolveSecretPath so `run` honors INFISICAL_ENVIRONMENT / INFISICAL_SECRET_PATH and .infisical.json defaults (defaultEnvironment/defaultSecretPath), matching `agent-proxy connect` (#319). --- packages/cmd/agent_proxy_run.go | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go index 5d40194e..110f6542 100644 --- a/packages/cmd/agent_proxy_run.go +++ b/packages/cmd/agent_proxy_run.go @@ -52,23 +52,14 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { 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")) } - environment, err := cmd.Flags().GetString("env") - if err != nil { - util.HandleError(err, "Unable to parse --env") - } - if !cmd.Flags().Changed("env") { - if envFromWorkspace := util.GetEnvFromWorkspaceFile(); envFromWorkspace != "" { - environment = envFromWorkspace - } - } + // Resolve --env/--path the same way `agent-proxy connect` does (flag > env var > .infisical.json), + // so the two subcommands behave consistently and honor INFISICAL_ENVIRONMENT / INFISICAL_SECRET_PATH. + environment := util.ResolveEnvironmentName(cmd) if environment == "" { - util.HandleError(fmt.Errorf("the --env flag is required")) + util.HandleError(fmt.Errorf("the environment is required; pass --env, set INFISICAL_ENVIRONMENT, or set defaultEnvironment in .infisical.json")) } - secretPath, err := cmd.Flags().GetString("path") - if err != nil { - util.HandleError(err, "Unable to parse --path") - } + secretPath := util.ResolveSecretPath(cmd) projectID, err := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "projectId", []string{util.INFISICAL_PROJECT_ID_NAME}, "") if err != nil { From 6ddcc52455e8f9ffa174647f073462d62371ecc2 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:59:05 +0530 Subject: [PATCH 13/13] fix(agent-proxy): address PR review findings - Scrub SSH_AUTH_SOCK / SSH_AGENT_PID / GPG_AGENT_INFO from the child so an agent can't use the developer's ssh/gpg agent as a signing oracle. - macOS: deny file-write* on credential paths (not just reads), so a run from $HOME can't write into ~/.ssh and friends. - Add .config/gh, .git-credentials, .npmrc, .gnupg to the default deny paths. - Use gofrs/flock for the local CA lock so the file builds on Windows. - Drop the redundant control-plane egress block: the child holds no token (env scrubbed, keyring/config masked), so reaching Infisical does nothing; the block broke legit use (Infisical as a proxied service, or --unmatched-host=allow) and was IP-bypassable anyway. --- go.mod | 2 +- go.sum | 183 -------------------------- packages/agentproxy/local.go | 4 - packages/agentproxy/local_ca_store.go | 18 +-- packages/agentproxy/local_test.go | 23 ---- packages/agentproxy/proxy.go | 5 - packages/cmd/agent_proxy.go | 8 ++ packages/cmd/agent_proxy_run.go | 35 ++--- packages/sandbox/seatbelt_profile.go | 9 ++ packages/sandbox/spec.go | 4 + 10 files changed, 37 insertions(+), 254 deletions(-) diff --git a/go.mod b/go.mod index 1743235c..a179117a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/fatih/semgroup v1.2.0 github.com/gitleaks/go-gitdiff v0.9.1 github.com/go-mysql-org/go-mysql v1.13.0 + github.com/gofrs/flock v0.8.1 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/h2non/filetype v1.1.3 @@ -111,7 +112,6 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/gofrs/flock v0.8.1 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/glog v1.2.5 // indirect diff --git a/go.sum b/go.sum index f23936e0..95e0e783 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -20,8 +18,6 @@ cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmW cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= -cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= @@ -31,35 +27,25 @@ cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNF cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0 h1:PQcPefKFdaIzjQFbiyOgAqyx8q5djaE7x9Sqe712DPA= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0 h1:/May9ojXjRkPBNVrq+oWLqmWCkr4OU5uRY29bu0mRyQ= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0 h1:9x7Bx0A9R5/M9jibeJeZWqjeVEIxYW9fZYqB9a70/bY= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw= cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ= -cloud.google.com/go/longrunning v0.5.9 h1:haH9pAuXdPAMqHvzX0zlWQigXT7B0+CL4/2nXXdBo5k= -cloud.google.com/go/longrunning v0.5.9/go.mod h1:HD+0l9/OOW0za6UWdKJtXoFAX/BGg/3Wj8p10NeWF7c= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1 h1:ukjixP1wl0LpnZ6LWtZJ0mX5tBmjp1f8Sqer8Z2OMUU= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0 h1:STgFzyU5/8miMl0//zKh2aQeTyeaUH3WN9bSUiJ09bA= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cloud.google.com/go/translate v1.10.3 h1:g+B29z4gtRGsiKDoTF+bNeH25bLRokAaElygX2FcZkE= -cloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUuKccphEpTJnWhS2jkQyMt6Y7pJCD7fY= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= @@ -68,14 +54,9 @@ github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9 github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8a+4nPE9g= github.com/BobuSumisu/aho-corasick v1.0.3/go.mod h1:hm4jLcvZKI2vRF2WDU1N4p/jpWtpOzp3nLmi9AzX/XE= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= -github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 h1:w0E0fgc1YafGEh5cROhlROMWXiNoZqApk2PDN0M1+Ns= github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/Infisical/go-keyring v1.0.2 h1:dWOkI/pB/7RocfSJgGXbXxLDcVYsdslgjEPmVhb+nl8= github.com/Infisical/go-keyring v1.0.2/go.mod h1:LWOnn/sw9FxDW/0VY+jHFAfOFEe03xmwBVSfJnBowto= github.com/Infisical/turn/v4 v4.0.1 h1:omdelNsnFfzS5cu86W5OBR68by68a8sva4ogR0lQQnw= @@ -86,28 +67,15 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+ github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= -github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= -github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= -github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= -github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= -github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da h1:8GUt8eRujhVEGZFFEjBj46YV4rDjvGrNxb0KMWYkL2I= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 h1:BUAU3CGlLvorLI26FmByPp2eC2qla6E1Tw+scpcg/to= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGLmAjMPwCCCo7Jf0W6f9slllCkkv7vyc1yOSg= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= @@ -142,11 +110,8 @@ github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.4 h1:w/jqZtC9YD4DS/Vp9GhWfWcCpuAL58oTnLoI8vE9YHU= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b h1:baFN6AnR0SeC194X2D292IUZcHDs4JjStpqtE70fjXE= github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b/go.mod h1:Ram6ngyPDmP+0t6+4T2rymv0w0BS9N8Ch5vvUJccw5o= @@ -154,7 +119,6 @@ github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= -github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -172,38 +136,23 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= -github.com/coreos/etcd v3.3.10+incompatible h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-etcd v2.0.0+incompatible h1:bXhRBIXoTm9BYHS3gE0TtQuyNZyeEMux2sDi4oo5YOo= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= -github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= -github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 h1:iwZdTE0PVqJCos1vaoKsclOGD3ADKpshg3SRtYBbwso= -github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM= -github.com/cznic/sortutil v0.0.0-20181122101858-f5f958428db8 h1:LpMLYGyy67BoAFGda1NeOBQwqlv7nUXpm+rIVHGxZZ4= -github.com/cznic/sortutil v0.0.0-20181122101858-f5f958428db8/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ= -github.com/cznic/strutil v0.0.0-20181122101858-275e90344537 h1:MZRmHqDBd0vxNwenEbKSQqRVT24d3C05ft8kduSwlqM= -github.com/cznic/strutil v0.0.0-20181122101858-275e90344537/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc= github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -233,11 +182,8 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= -github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= @@ -257,17 +203,12 @@ github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w= github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= -github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gitleaks/go-gitdiff v0.9.1 h1:ni6z6/3i9ODT685OLCTf+s/ERlWUNWQF4x1pvoNICw0= github.com/gitleaks/go-gitdiff v0.9.1/go.mod h1:pKz0X4YzCKZs30BL+weqBIG7mx0jl4tF1uXV9ZyNvrA= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -293,8 +234,6 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= -github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -323,7 +262,6 @@ github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0 h1:jlYHihg//f7RRwuPfptm04yp4s7O6Kw8EZiVYIGcH0g= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -350,8 +288,6 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= -github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= @@ -370,15 +306,11 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-pkcs11 v0.3.0 h1:PVRnTgtArZ3QQqTGtbtjtnIkzl2iY2kt24yqbrf7td8= -github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0 h1:wCKgOCHuUEVfsaQLpPSJb7VdYCdTVZQAuOdYm1yc/60= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -393,7 +325,6 @@ github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 h1:+J3r2e8+RsmN3vKfo75g0YSY61ms37qzPglu4p0sGro= github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= -github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= @@ -415,67 +346,45 @@ github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pw github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= -github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM= -github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= -github.com/hashicorp/consul/api v1.1.0 h1:BNQPM9ytxj6jbjjdRPioQ94T6YXriSopn0i8COv6SRA= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/sdk v0.1.1 h1:LnuDWGNsoajlhGyHJvuWW6FVqRl8JOTPqS6CPTsYjhY= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3 h1:zKjpN5BK/P5lMYrLmBHdBULWbJ0XpYR+7NGzqkZzoD4= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-rootcerts v1.0.0 h1:Rqb66Oo1X/eSV1x66xbDccZjhJigjg0+e82kpwzSwCI= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1 h1:sNCoNyDEvN1xa+X0baata4RdcpKwcMS6DH+xwfqPgjw= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0 h1:WhIgCr5a7AaVH6jPUwjtRuuE7/RDufnUvzIr48smyxs= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3 h1:EmmoJme1matNzb+hMpDuR/0sbJSUisxyqBGG676r31M= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2 h1:YZ7UKsJv+hKjqGVUUbtE3HNj79Eln2oQ75tniF6iPt0= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465 h1:KwWnWVWCNtNq/ewIX7HIKnELmEx2nDP42yskD/pi7QE= -github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -509,37 +418,26 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmoiron/sqlx v1.3.3 h1:j82X0bf7oQ27XeqxicSZsTU5suPwKElg3oyxNn43iTk= -github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= -github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= -github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -567,22 +465,15 @@ github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= -github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= -github.com/mitchellh/cli v1.0.0 h1:iGBIsUe3+HZ/AD/Vd7DErOt5sU9fa8Uj7A2s1aggv1Y= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0 h1:lfGJxY7ToLJQjHHwi0EX6uYBdK78egf954SQl13PQJc= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0 h1:C+X3KsSTLFVBr/tK1eYN/vs4rJcvsiLU338UhYPJWeY= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -591,8 +482,6 @@ github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxd github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/moby/spdystream v0.4.0 h1:Vy79D6mHeJJjiPdFEL2yku1kl0chZpJfZcPpb16BRl8= -github.com/moby/spdystream v0.4.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -601,8 +490,6 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= -github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a h1:jlDOeO5TU0pYlbc/y6PFguab5IjANI0Knrpg3u/ton4= @@ -621,9 +508,6 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oiweiwei/go-math v1.0.0 h1:eBxtydohwi0RTfSX8EPQRO7Xt8j4Ck5v7PVv8Hvp7jg= github.com/oiweiwei/go-math v1.0.0/go.mod h1:rWG2PIZRYIjovChTlCcssArtO/s2pVj38c1h2QfH1UM= @@ -643,18 +527,13 @@ github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= github.com/oracle/oci-go-sdk/v65 v65.95.2 h1:0HJ0AgpLydp/DtvYrF2d4str2BjXOVAeNbuW7E07g94= github.com/oracle/oci-go-sdk/v65 v65.95.2/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c h1:Lgl0gzECD8GnQ5QCWA8o6BtfL6mDH5rQgM4/fX3avOs= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= -github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pingcap/errors v0.11.5-0.20250318082626-8f80e5cb09ec h1:3EiGmeJWoNixU+EwllIn26x6s4njiWRXewdx2zlYa84= github.com/pingcap/errors v0.11.5-0.20250318082626-8f80e5cb09ec/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= -github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE= -github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4= github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a h1:WIhmJBlNGmnCWH6TLMdZfNEDaiU8cFpZe3iaqDbQ0M8= github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a/go.mod h1:ORfBOFp1eteu2odzsyaxI+b8TzJwgjwyQcGhI+9SfEA= github.com/pingcap/tidb/pkg/parser v0.0.0-20250421232622-526b2c79173d h1:3Ej6eTuLZp25p3aH/EXdReRHY12hjZYs3RrGp7iLdag= @@ -674,51 +553,36 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.10.1 h1:VasscCm72135zRysgrJDKsntdmPN+OuU3+nnHYA9wyc= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a h1:Ey0XWvrg6u6hyIn1Kd/jCCmL+bMv9El81tvuGBbxZGg= github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a/go.mod h1:oa2sAs9tGai3VldabTV0eWejt/O4/OOD7azP8GaikqU= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= -github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= -github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f h1:UFr9zpz4xgTnIE5yIMtWAMngCdZ9p/+q6lTbgelo80M= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smallnest/resp3 v0.0.0-20251228151914-4f2fa7427e69 h1:AkDv2coi+ZsMlEp/6V21FWxdswSIEzqflgJ6snIQG+U= github.com/smallnest/resp3 v0.0.0-20251228151914-4f2fa7427e69/go.mod h1:cmfXTZVXEA7xFOYcGnpKp2VeFf6FUHmxdKQHVNE6BXY= @@ -750,8 +614,6 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.8.1 h1:Kq1fyeebqsBfbjZj4EL7gj2IO0mMaiyjYUWcUsl2O44= github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= -github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= -github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -774,13 +636,10 @@ github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= -github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde h1:AMNpJRc7P+GTwVbl8DkK2I9I8BBUzNiHuH/tlxrpan0= github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde/go.mod h1:MvrEmduDUz4ST5pGZ7CABCnOU5f3ZiOAZzT6b1A6nX8= -github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8 h1:3SVOIvH7Ae1KRYyQWRjXWJEA9sS/c/pjvH++55Gr648= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= -github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU= github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/wasilibs/go-re2 v1.10.0 h1:vQZEBYZOCA9jdBMmrO4+CvqyCj0x4OomXTJ4a5/urQ0= github.com/wasilibs/go-re2 v1.10.0/go.mod h1:k+5XqO2bCJS+QpGOnqugyfwC04nw0jaglmjrrkG8U6o= @@ -798,7 +657,6 @@ github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77 h1:ESFSdwYZvkeru3RtdrYueztKhOBCSAAzS4Gf+k0tEow= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM= @@ -810,13 +668,9 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.etcd.io/etcd/api/v3 v3.5.0 h1:GsV3S+OfZEOCNXdtNkBSR7kgLobAa/SO6tCxRa0GAYw= go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0 h1:2aQv6F436YnN7I4VbI8PPYrBhu+SmrTaADcf8Mi/6PU= go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0 h1:ftQ0nOOHMcbMS3KIaDQ0g5Qcd6bhaBrQT6b89DfwLTs= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= go.mongodb.org/mongo-driver v1.10.0/go.mod h1:wsihk0Kdgv8Kqu1Anit4sfK+22vSFbUrAVEYRhCXrA8= go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= @@ -836,8 +690,6 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -846,8 +698,6 @@ go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= @@ -900,7 +750,6 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c= golang.org/x/exp v0.0.0-20250228200357-dead58393ab7/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -913,10 +762,8 @@ golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRu golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= @@ -929,8 +776,6 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1149,7 +994,6 @@ golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= @@ -1184,8 +1028,6 @@ google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -1231,8 +1073,6 @@ google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH8 google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/bytestream v0.0.0-20260203192932-546029d2fa20 h1:zQTtWukWCqGTV6Pt60SqvPGnEi2CE3PeeIRlu4SYgAc= -google.golang.org/genproto/googleapis/bytestream v0.0.0-20260203192932-546029d2fa20/go.mod h1:Tej9lWiwVvQJP+b43pjJIsr/3mZycXWCIyoiXmbFf40= google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= @@ -1278,10 +1118,7 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= @@ -1304,7 +1141,6 @@ honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= k8s.io/api v0.31.4 h1:I2QNzitPVsPeLQvexMEsj945QumYraqv9m74isPDKhM= k8s.io/api v0.31.4/go.mod h1:d+7vgXLvmcdT1BCo79VEgJxHHryww3V5np2OYTr6jdw= @@ -1312,33 +1148,14 @@ k8s.io/apimachinery v0.31.4 h1:8xjE2C4CzhYVm9DGf60yohpNUh5AEBnPxCryPBECmlM= k8s.io/apimachinery v0.31.4/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= k8s.io/client-go v0.31.4 h1:t4QEXt4jgHIkKKlx06+W3+1JOwAFU/2OPiOo7H92eRQ= k8s.io/client-go v0.31.4/go.mod h1:kvuMro4sFYIa8sulL5Gi5GFqUPvfH2O/dXuKstbaaeg= -k8s.io/gengo/v2 v2.0.0-20240826214909-a7b603a56eb7 h1:cErOOTkQ3JW19o4lo91fFurouhP8NcoBvb7CkvhZZpk= -k8s.io/gengo/v2 v2.0.0-20240826214909-a7b603a56eb7/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -lukechampine.com/frand v1.5.1 h1:fg0eRtdmGFIxhP5zQJzM1lFDbD6CUfu/f+7WgAZd5/w= -lukechampine.com/frand v1.5.1/go.mod h1:4VstaWc2plN4Mjr10chUD46RAVGWhpkZ5Nja8+Azp0Q= -modernc.org/golex v1.1.0 h1:dmSaksHMd+y6NkBsRsCShNPRaSNCNH+abrVm5/gZic8= -modernc.org/golex v1.1.0/go.mod h1:2pVlfqApurXhR1m0N+WDYu6Twnc4QuvO4+U8HnwoiRA= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/parser v1.1.0 h1:XoClYpoz2xHEDIteSQ7tICOTFcNwBI7XRCeghUS6SNI= -modernc.org/parser v1.1.0/go.mod h1:CXl3OTJRZij8FeMpzI3Id/bjupHf0u9HSrCUP4Z9pbA= -modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= -modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= -modernc.org/y v1.1.0 h1:JdIvLry+rKeSsVNRCdr6YWYimwwNm0GXtzxid77VfWc= -modernc.org/y v1.1.0/go.mod h1:Iz3BmyIS4OwAbwGaUS7cqRrLsSsfp2sFWtpzX+P4CsE= -rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= diff --git a/packages/agentproxy/local.go b/packages/agentproxy/local.go index 8db544f0..17f8def1 100644 --- a/packages/agentproxy/local.go +++ b/packages/agentproxy/local.go @@ -23,10 +23,6 @@ type LocalOptions struct { // cannot read the key. CADir string - // InfisicalHost (bare hostname) is always refused through the proxy, keeping the control plane - // unreachable from the sandbox as an invariant, not just because the child holds no token. - InfisicalHost string - // IdentityID/IdentityName label activity records (no wire JWT to decode them from). IdentityID string IdentityName string diff --git a/packages/agentproxy/local_ca_store.go b/packages/agentproxy/local_ca_store.go index 24116546..be2a90c2 100644 --- a/packages/agentproxy/local_ca_store.go +++ b/packages/agentproxy/local_ca_store.go @@ -9,7 +9,7 @@ import ( "path/filepath" "time" - "golang.org/x/sys/unix" + "github.com/gofrs/flock" ) const ( @@ -121,18 +121,12 @@ func writeFileAtomic(path string, data []byte, perm os.FileMode) error { return os.Rename(tmpName, path) } -// lockDir takes an exclusive flock on a lockfile in dir; the returned func releases it. +// 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) { - f, err := os.OpenFile(filepath.Join(dir, ".ca.lock"), os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return nil, fmt.Errorf("failed to open CA lock: %w", err) - } - if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil { - f.Close() + 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() { - _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) - _ = f.Close() - }, nil + return func() { _ = fl.Unlock() }, nil } diff --git a/packages/agentproxy/local_test.go b/packages/agentproxy/local_test.go index af3a8e73..7228da3d 100644 --- a/packages/agentproxy/local_test.go +++ b/packages/agentproxy/local_test.go @@ -196,29 +196,6 @@ func TestRemoteModeChallengesWithoutProxyAuth(t *testing.T) { } } -func TestLocalModeBlocksInfisicalHost(t *testing.T) { - local := &LocalOptions{ - ProjectID: "proj", Environment: "prod", SecretPath: "/", - UserToken: func() string { return "dev-token" }, - InfisicalHost: "app.infisical.com", - } - client := newLocalTestProxy(t, local, nil, &stubRoundTripper{respBody: "ok"}) - _ = client.SetDeadline(time.Now().Add(10 * time.Second)) - - // Case-insensitive hostname match; blocked even though unmatched hosts default to allow. - if _, err := fmt.Fprintf(client, "GET http://App.Infisical.Com/api/v1/secrets HTTP/1.1\r\nHost: app.infisical.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.StatusForbidden { - t.Fatalf("expected 403 for the Infisical host, got %d", resp.StatusCode) - } -} - func TestUnmatchedBlockRespectsAllowHost(t *testing.T) { local := &LocalOptions{ ProjectID: "proj", Environment: "prod", SecretPath: "/", diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index df2b6993..f159292b 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -587,11 +587,6 @@ type forwardOutcome struct { } func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt string, scope agentScope) (*http.Response, forwardOutcome, error) { - // Local mode: the Infisical control plane is never reachable from the sandbox, even under allow. - if l := ps.opts.Local; l != nil && l.InfisicalHost != "" && strings.EqualFold(hostname, l.InfisicalHost) { - return nil, forwardOutcome{}, fmt.Errorf("host %q is the Infisical API and is not reachable from the sandboxed agent: %w", hostname, errHostBlocked) - } - services, err := ps.cache.get(jwt, scope) if err != nil { return nil, forwardOutcome{}, fmt.Errorf("failed to resolve agent permissions: %w", err) diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index 6385be02..70533331 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -84,6 +84,14 @@ var credentialEnvKeys = []string{ util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, } +// Authentication-agent handles: not secret values, but a socket the child could use as a signing +// oracle for the developer's SSH/GPG keys. Scrubbed by default; --pass-env re-admits a specific one. +var authAgentEnvKeys = []string{ + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + "GPG_AGENT_INFO", +} + var requiredNoProxy = []string{"localhost", "127.0.0.1"} func mergeNoProxy(operatorEntries ...string) string { diff --git a/packages/cmd/agent_proxy_run.go b/packages/cmd/agent_proxy_run.go index 110f6542..5c30a80c 100644 --- a/packages/cmd/agent_proxy_run.go +++ b/packages/cmd/agent_proxy_run.go @@ -18,7 +18,6 @@ import ( "github.com/Infisical/infisical-merge/packages/agentproxy" "github.com/Infisical/infisical-merge/packages/api" - "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/sandbox" "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" @@ -88,21 +87,13 @@ func runAgentProxyRun(cmd *cobra.Command, args []string) { httpClient := resty.New().SetAuthToken(src.token()) placeholders := fetchLocalProxiedServiceConfig(httpClient, projectID, environment, secretPath) - // The control-plane fence keys off this host, so refuse to start if we can't derive it rather than - // silently running with the fence disabled. - apiHost := infisicalAPIHost() - if apiHost == "" { - util.HandleError(fmt.Errorf("could not determine the Infisical API host from domain %q; pass a full URL via --domain (e.g. https://app.infisical.com)", config.INFISICAL_URL)) - } - local := &agentproxy.LocalOptions{ - ProjectID: projectID, - Environment: environment, - SecretPath: secretPath, - UserToken: src.token, - InfisicalHost: apiHost, - IdentityID: src.label, - IdentityName: src.label, + 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. @@ -435,17 +426,6 @@ func localProxyURL(proxyAddr string) string { return u.String() } -// infisicalAPIHost is the bare hostname of the configured Infisical API (the proxy refuses egress to -// it). ParseRequestURI (not the lenient url.Parse) so a scheme-less domain yields "" and the caller -// fails closed instead of running with the control-plane fence silently disabled. -func infisicalAPIHost() string { - u, err := url.ParseRequestURI(config.INFISICAL_URL) - if err != nil { - return "" - } - return u.Hostname() -} - // 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 { @@ -464,6 +444,9 @@ func buildLocalAgentEnv(cmd *cobra.Command, proxy, caPath string, placeholders m for _, k := range credentialEnvKeys { stale[k] = true } + for _, k := range authAgentEnvKeys { + stale[k] = true + } stale[util.INFISICAL_TOKEN_NAME] = true stale[util.INFISICAL_DOMAIN_ENV_NAME] = true stale[util.INFISICAL_VAULT_FILE_PASSPHRASE_ENV_NAME] = true diff --git a/packages/sandbox/seatbelt_profile.go b/packages/sandbox/seatbelt_profile.go index 82199031..4fa6896e 100644 --- a/packages/sandbox/seatbelt_profile.go +++ b/packages/sandbox/seatbelt_profile.go @@ -109,6 +109,15 @@ func generateSeatbeltProfile(spec SandboxSpec) string { } add(`(allow file-write* (subpath ` + escapeSBPL(p) + `))`) } + add("") + + // Re-deny writes to credential paths after the write allows (SBPL is last-match-wins), so a writable + // cwd or write path that contains a denied path (e.g. running the agent from $HOME) can't write into + // it, for example appending 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" } diff --git a/packages/sandbox/spec.go b/packages/sandbox/spec.go index f0994ef4..28d02e31 100644 --- a/packages/sandbox/spec.go +++ b/packages/sandbox/spec.go @@ -56,6 +56,10 @@ func DefaultDenyPaths(home string) []string { ".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)) for _, r := range rel {