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)