From ac601b7e59964970b788f99bbdc0a46c93ec2f7b Mon Sep 17 00:00:00 2001 From: GRAB Racem Date: Tue, 3 Nov 2026 21:46:40 +0100 Subject: [PATCH] fix(security): remove startup secrets from the environment before child processes spawn ARTEX_PG_DSN, ANTHROPIC_API_KEY and OPENAI_API_KEY are read only during startup (server.NewManager and agent.FromEnv in server.New), but stayed in the process environment. Agent shells, custom tools and MCP stdio servers copy os.Environ() when they start, so they could read the database password and the LLM keys. main now unsets the three variables right after server.New returns. --- cmd/artex/main.go | 14 ++++++++++++++ cmd/artex/main_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/cmd/artex/main.go b/cmd/artex/main.go index 0ce0b02..46c5ab3 100644 --- a/cmd/artex/main.go +++ b/cmd/artex/main.go @@ -94,6 +94,7 @@ func run() int { } log.Printf("[config] skill directory: %s", skillDir) srv := server.New(ctx, mgr, skillDir, *dataDir, config.BaseDir()) + scrubSecretEnv() httpSrv := &http.Server{ Addr: *addr, Handler: srv.Handler(), @@ -117,6 +118,19 @@ func run() int { return 0 } +// secretEnv lists the credentials read from the environment only at startup: +// ARTEX_PG_DSN by server.NewManager, the API keys by agent.FromEnv in server.New. +var secretEnv = []string{"ARTEX_PG_DSN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"} + +// scrubSecretEnv removes secretEnv from the process environment once startup has +// read it. Child processes (agent shells, custom tools, MCP stdio servers) copy +// os.Environ() when they are spawned, so they no longer inherit these values. +func scrubSecretEnv() { + for _, k := range secretEnv { + _ = os.Unsetenv(k) + } +} + // shutdownContext deliberately does not derive from signalCtx. If it did, the // parent's plain context.Canceled could win the race before AbortShutdown was // attached to the child, losing the diagnostic cause in every running Agent. diff --git a/cmd/artex/main_test.go b/cmd/artex/main_test.go index 149c7c6..b89e44d 100644 --- a/cmd/artex/main_test.go +++ b/cmd/artex/main_test.go @@ -2,11 +2,42 @@ package main import ( "context" + "os/exec" + "strings" "testing" "github.com/Autumn-27/artex/agent" ) +// A child process spawned after startup must not see the startup secrets, while +// other variables still pass through. +func TestScrubSecretEnvHidesSecretsFromChildren(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("no sh in PATH") + } + for _, k := range secretEnv { + t.Setenv(k, "leak-"+k) + } + t.Setenv("ARTEX_SCRUB_KEEP", "kept") + + scrubSecretEnv() + + out, err := exec.Command(sh, "-c", "env").Output() + if err != nil { + t.Fatal(err) + } + env := "\n" + string(out) + for _, k := range secretEnv { + if strings.Contains(env, "\n"+k+"=") { + t.Errorf("%s still reaches child processes", k) + } + } + if !strings.Contains(env, "\nARTEX_SCRUB_KEEP=kept\n") { + t.Errorf("non-secret variable did not reach the child process") + } +} + func TestShutdownContextPreservesNamedCause(t *testing.T) { signalCtx, signalCancel := context.WithCancel(context.Background()) ctx, shutdown := shutdownContext(signalCtx)