This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new d6ccb4080 fix(ai): isolate CLI agent subprocess environments (#2207)
d6ccb4080 is described below

commit d6ccb408026de81c068e56f7996e9adeecab1bb5
Author: xdz997 <[email protected]>
AuthorDate: Wed Aug 19 14:21:16 2026 +0800

    fix(ai): isolate CLI agent subprocess environments (#2207)
---
 deploy/.env.example                                |   6 +
 deploy/docker-compose.yml                          |   1 +
 .../studio/ops/ai/ClaudeCodeAgentProvider.java     |   5 +-
 .../rocketmq/studio/ops/ai/CliAgentProvider.java   |  20 ++-
 .../studio/ops/ai/CliProcessEnvironment.java       | 135 +++++++++++++++++++++
 .../rocketmq/studio/ops/ai/LlmProperties.java      |   9 +-
 .../rocketmq/studio/ops/ai/QoderAgentProvider.java |   4 +
 server/src/main/resources/application.yml          |   1 +
 .../studio/ops/ai/ClaudeCodeAgentProviderTest.java |  45 ++++++-
 .../studio/ops/ai/CliAgentProviderTest.java        |  49 +++++++-
 .../studio/ops/ai/CliProcessEnvironmentTest.java   | 132 ++++++++++++++++++++
 11 files changed, 394 insertions(+), 13 deletions(-)

diff --git a/deploy/.env.example b/deploy/.env.example
index 71d38ad0e..3d7f82d1d 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -26,3 +26,9 @@ STUDIO_METRICS_PROMETHEUS_BASE_URL=
 STUDIO_METRICS_PROMETHEUS_USERNAME=
 STUDIO_METRICS_PROMETHEUS_PASSWORD=
 STUDIO_METRICS_PROMETHEUS_BEARER_TOKEN=
+
+# Optional comma-separated environment variable names that CLI-backed AI
+# providers may inherit in addition to Studio's minimal built-in allowlist.
+# Values are read from the rocketmq-server environment; do not list unrelated
+# server secrets here.
+RMQ_LLM_CLI_ALLOWED_ENVIRONMENT=
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 3c98eca2e..d68278bcf 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -51,6 +51,7 @@ services:
       STUDIO_ROCKETMQ_NAMESRV_ADDR: 
${STUDIO_ROCKETMQ_NAMESRV_ADDR:-nameserver:9876}
       RMQ_LLM_TOKEN: ${RMQ_LLM_TOKEN:-}
       RMQ_ANTHROPIC_BASE_URL: ${RMQ_ANTHROPIC_BASE_URL:-}
+      RMQ_LLM_CLI_ALLOWED_ENVIRONMENT: ${RMQ_LLM_CLI_ALLOWED_ENVIRONMENT:-}
     expose:
       - "8888"
     networks:
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
index 8e83df376..033eb6ff3 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProvider.java
@@ -56,7 +56,8 @@ public class ClaudeCodeAgentProvider extends CliAgentProvider 
{
     private final LlmProperties llmProperties;
     private final ObjectMapper objectMapper = new ObjectMapper();
 
-    public ClaudeCodeAgentProvider(LlmProperties llmProperties) {
+    public ClaudeCodeAgentProvider(LlmProperties llmProperties, 
CliProcessEnvironment processEnvironment) {
+        super(processEnvironment);
         this.llmProperties = llmProperties;
     }
 
@@ -95,7 +96,7 @@ public class ClaudeCodeAgentProvider extends CliAgentProvider 
{
         command.add("--include-partial-messages");
 
         ProcessBuilder builder = new ProcessBuilder(command);
-        builder.environment().putAll(childEnv(config));
+        processEnvironment().apply(builder, childEnv(config));
         builder.redirectErrorStream(false);
         try {
             Process process = builder.start();
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
index 22411ffeb..f038c0ff4 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
@@ -25,6 +25,7 @@ import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
 import java.util.concurrent.ExecutionException;
@@ -42,18 +43,28 @@ public abstract class CliAgentProvider implements 
AgentProvider {
     private static final long TIMEOUT_SECONDS = 180;
     private static final int MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
 
+    private final CliProcessEnvironment processEnvironment;
+
+    protected CliAgentProvider(CliProcessEnvironment processEnvironment) {
+        this.processEnvironment = Objects.requireNonNull(processEnvironment, 
"processEnvironment");
+    }
+
     protected abstract List<String> buildCommand(LlmConfigVO config, String 
prompt, String modelOverride);
 
     protected abstract Map<String, String> childEnv(LlmConfigVO config);
 
     protected abstract String binaryName();
 
+    protected final CliProcessEnvironment processEnvironment() {
+        return processEnvironment;
+    }
+
     @Override
     public boolean available() {
         try {
-            Process process = new ProcessBuilder("sh", "-c", "command -v " + 
binaryName())
-                    .redirectErrorStream(true)
-                    .start();
+            ProcessBuilder builder = new ProcessBuilder("sh", "-c", "command 
-v " + binaryName());
+            processEnvironment.apply(builder, Map.of());
+            Process process = builder.redirectErrorStream(true).start();
             boolean finished = process.waitFor(5, TimeUnit.SECONDS);
             return finished && process.exitValue() == 0;
         } catch (IOException | InterruptedException exception) {
@@ -73,8 +84,7 @@ public abstract class CliAgentProvider implements 
AgentProvider {
         }
         List<String> command = buildCommand(config, prompt, modelOverride);
         ProcessBuilder builder = new ProcessBuilder(command);
-        Map<String, String> env = builder.environment();
-        env.putAll(childEnv(config));
+        processEnvironment.apply(builder, childEnv(config));
         // Merge stderr into stdout and drain the stream on a background 
thread. Reading stdout
         // then stderr sequentially on the caller thread deadlocks once the 
child fills a pipe
         // buffer (64 KiB), and a timeout that runs only after both reads can 
never fire while the
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironment.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironment.java
new file mode 100644
index 000000000..cce3d5f49
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironment.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.ai;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Builds the environment for CLI-backed AI providers without exposing every
+ * variable from the Studio server process.
+ */
+@Component
+public class CliProcessEnvironment {
+
+    private static final Pattern ENVIRONMENT_NAME = 
Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
+
+    /**
+     * Runtime variables used to find executables and user-scoped CLI state,
+     * create temporary files, select a locale, and reach the provider through
+     * explicitly configured proxies or certificate stores.
+     */
+    private static final List<String> DEFAULT_ALLOWED_NAMES = List.of(
+            "PATH",
+            "HOME",
+            "USERPROFILE",
+            "XDG_CONFIG_HOME",
+            "XDG_CACHE_HOME",
+            "XDG_DATA_HOME",
+            "TMPDIR",
+            "TMP",
+            "TEMP",
+            "LANG",
+            "LANGUAGE",
+            "LC_ALL",
+            "LC_CTYPE",
+            "TERM",
+            "SSL_CERT_FILE",
+            "SSL_CERT_DIR",
+            "NODE_EXTRA_CA_CERTS",
+            "HTTP_PROXY",
+            "HTTPS_PROXY",
+            "NO_PROXY",
+            "http_proxy",
+            "https_proxy",
+            "no_proxy",
+            "SystemRoot",
+            "ComSpec",
+            "PATHEXT");
+
+    private final Set<String> allowedNames;
+
+    @Autowired
+    public CliProcessEnvironment(LlmProperties properties) {
+        this(properties == null ? List.of() : 
properties.getCliAllowedEnvironment());
+    }
+
+    CliProcessEnvironment(Collection<String> additionalAllowedNames) {
+        LinkedHashSet<String> names = new 
LinkedHashSet<>(DEFAULT_ALLOWED_NAMES);
+        if (additionalAllowedNames != null) {
+            additionalAllowedNames.stream()
+                    .filter(StringUtils::hasText)
+                    .map(String::trim)
+                    .filter(CliProcessEnvironment::isValidName)
+                    .forEach(names::add);
+        }
+        this.allowedNames = Collections.unmodifiableSet(names);
+    }
+
+    /**
+     * Replaces the builder's inherited environment with the isolated child
+     * environment. Provider-specific values are applied last so the selected
+     * request configuration wins over any allowed parent value.
+     */
+    void apply(ProcessBuilder builder, Map<String, String> 
providerEnvironment) {
+        Map<String, String> target = builder.environment();
+        Map<String, String> isolated = build(target, providerEnvironment);
+        target.clear();
+        target.putAll(isolated);
+    }
+
+    Map<String, String> build(Map<String, String> parentEnvironment,
+                              Map<String, String> providerEnvironment) {
+        Map<String, String> result = new LinkedHashMap<>();
+        if (parentEnvironment != null) {
+            for (String name : allowedNames) {
+                if (parentEnvironment.containsKey(name)) {
+                    String value = parentEnvironment.get(name);
+                    if (value != null) {
+                        result.put(name, value);
+                    }
+                }
+            }
+        }
+        if (providerEnvironment != null) {
+            providerEnvironment.forEach((name, value) -> {
+                if (isValidName(name) && value != null) {
+                    result.put(name, value);
+                }
+            });
+        }
+        return result;
+    }
+
+    Set<String> allowedNames() {
+        return allowedNames;
+    }
+
+    private static boolean isValidName(String name) {
+        return name != null && ENVIRONMENT_NAME.matcher(name).matches();
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmProperties.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmProperties.java
index 62dde47e8..734c02219 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmProperties.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmProperties.java
@@ -19,14 +19,17 @@ package org.apache.rocketmq.studio.ops.ai;
 import lombok.Data;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 
+import java.util.ArrayList;
+import java.util.List;
+
 /**
- * LLM credentials supplied through the environment. The token is a secret and
- * must never be persisted or logged; it is injected as RMQ_LLM_TOKEN into the
- * container and bound here at startup.
+ * LLM runtime settings supplied through the environment. Tokens and inherited
+ * CLI environment values must never be persisted or logged.
  */
 @Data
 @ConfigurationProperties(prefix = "studio.llm")
 public class LlmProperties {
     private String token;
     private String anthropicBaseUrl;
+    private List<String> cliAllowedEnvironment = new ArrayList<>();
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/QoderAgentProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/QoderAgentProvider.java
index a0b954458..0b01dbeb5 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/QoderAgentProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/QoderAgentProvider.java
@@ -33,6 +33,10 @@ public class QoderAgentProvider extends CliAgentProvider {
 
     public static final String ENGINE = "qoder";
 
+    public QoderAgentProvider(CliProcessEnvironment processEnvironment) {
+        super(processEnvironment);
+    }
+
     @Override
     public String engine() {
         return ENGINE;
diff --git a/server/src/main/resources/application.yml 
b/server/src/main/resources/application.yml
index a4104aa45..db9260acf 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -71,3 +71,4 @@ studio:
   llm:
     token: ${RMQ_LLM_TOKEN:}
     anthropic-base-url: ${RMQ_ANTHROPIC_BASE_URL:}
+    cli-allowed-environment: ${RMQ_LLM_CLI_ALLOWED_ENVIRONMENT:}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
index 8cedc2ddd..d9767c31c 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/ClaudeCodeAgentProviderTest.java
@@ -51,15 +51,56 @@ class ClaudeCodeAgentProviderTest {
                         .isEqualTo(504));
     }
 
+    @Test
+    void streamUsesTheIsolatedEnvironment() {
+        RecordingEnvironment processEnvironment = new RecordingEnvironment();
+        TestClaudeCodeAgentProvider provider = new TestClaudeCodeAgentProvider(
+                List.of("sh", "-c",
+                        "printf eyJ0eXBlIjoicmVzdWx0IiwicmVzdWx0IjoiZG9uZSJ9 | 
base64 -d"),
+                5,
+                processEnvironment,
+                Map.of("ANTHROPIC_AUTH_TOKEN", "request-token"));
+
+        provider.stream(LlmConfigVO.builder().build(), "prompt", null, ignored 
-> { });
+
+        
assertThat(processEnvironment.childEnvironments).singleElement().satisfies(environment
 ->
+                assertThat(environment)
+                        .containsEntry("ANTHROPIC_AUTH_TOKEN", "request-token")
+                        .doesNotContainKey("SERVER_SECRET"));
+    }
+
+    private static final class RecordingEnvironment extends 
CliProcessEnvironment {
+        private final List<Map<String, String>> childEnvironments = new 
ArrayList<>();
+
+        RecordingEnvironment() {
+            super(List.of());
+        }
+
+        @Override
+        void apply(ProcessBuilder builder, Map<String, String> 
providerEnvironment) {
+            builder.environment().put("SERVER_SECRET", 
"must-not-cross-boundary");
+            super.apply(builder, providerEnvironment);
+            childEnvironments.add(Map.copyOf(builder.environment()));
+        }
+    }
+
     private static class TestClaudeCodeAgentProvider extends 
ClaudeCodeAgentProvider {
 
         private final List<String> command;
         private final long timeoutSeconds;
+        private final Map<String, String> environment;
 
         TestClaudeCodeAgentProvider(List<String> command, long timeoutSeconds) 
{
-            super(null);
+            this(command, timeoutSeconds, new 
CliProcessEnvironment(List.of()), Map.of());
+        }
+
+        TestClaudeCodeAgentProvider(List<String> command, long timeoutSeconds,
+                                    CliProcessEnvironment processEnvironment,
+                                    Map<String, String> environment) {
+            super(null, processEnvironment);
             this.command = command;
             this.timeoutSeconds = timeoutSeconds;
+            this.environment = environment;
         }
 
         @Override
@@ -74,7 +115,7 @@ class ClaudeCodeAgentProviderTest {
 
         @Override
         protected Map<String, String> childEnv(LlmConfigVO config) {
-            return Map.of();
+            return environment;
         }
 
         @Override
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
index 54a2226f7..19fc6aac7 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
@@ -12,6 +12,7 @@ package org.apache.rocketmq.studio.ops.ai;
 
 import org.junit.jupiter.api.Test;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 
@@ -23,14 +24,26 @@ class CliAgentProviderTest {
     private static final class FakeCli extends CliAgentProvider {
         private final String script;
         private final int outputLimitBytes;
+        private final Map<String, String> environment;
 
         FakeCli(String script) {
             this(script, Integer.MAX_VALUE);
         }
 
         FakeCli(String script, int outputLimitBytes) {
+            this(script, outputLimitBytes, new 
CliProcessEnvironment(List.of()), Map.of());
+        }
+
+        FakeCli(String script, CliProcessEnvironment processEnvironment, 
Map<String, String> environment) {
+            this(script, Integer.MAX_VALUE, processEnvironment, environment);
+        }
+
+        FakeCli(String script, int outputLimitBytes, CliProcessEnvironment 
processEnvironment,
+                Map<String, String> environment) {
+            super(processEnvironment);
             this.script = script;
             this.outputLimitBytes = outputLimitBytes;
+            this.environment = environment;
         }
 
         @Override
@@ -45,7 +58,7 @@ class CliAgentProviderTest {
 
         @Override
         protected Map<String, String> childEnv(LlmConfigVO config) {
-            return Map.of();
+            return environment;
         }
 
         @Override
@@ -59,6 +72,23 @@ class CliAgentProviderTest {
         }
     }
 
+    private static final class RecordingEnvironment extends 
CliProcessEnvironment {
+        private final List<Map<String, String>> providerEnvironments = new 
ArrayList<>();
+        private final List<Map<String, String>> childEnvironments = new 
ArrayList<>();
+
+        RecordingEnvironment() {
+            super(List.of());
+        }
+
+        @Override
+        void apply(ProcessBuilder builder, Map<String, String> 
providerEnvironment) {
+            builder.environment().put("SERVER_SECRET", 
"must-not-cross-boundary");
+            super.apply(builder, providerEnvironment);
+            providerEnvironments.add(Map.copyOf(providerEnvironment));
+            childEnvironments.add(Map.copyOf(builder.environment()));
+        }
+    }
+
     @Test
     void completeSurvivesLargeStderrOutput() {
         // Write well over the 64 KiB pipe buffer to stderr, then print the 
completion on stdout.
@@ -94,4 +124,21 @@ class CliAgentProviderTest {
 
         assertThat(cli.complete(null, "prompt", null)).isNotEmpty();
     }
+
+    @Test
+    void availabilityAndCompletionUseTheIsolatedEnvironment() {
+        RecordingEnvironment processEnvironment = new RecordingEnvironment();
+        FakeCli cli = new FakeCli(
+                "printf '%s' \"$PROVIDER_TOKEN\"",
+                processEnvironment,
+                Map.of("PROVIDER_TOKEN", "request-token"));
+
+        assertThat(cli.complete(null, "prompt", 
null)).isEqualTo("request-token");
+        assertThat(processEnvironment.providerEnvironments)
+                .containsExactly(Map.of(), Map.of("PROVIDER_TOKEN", 
"request-token"));
+        
assertThat(processEnvironment.childEnvironments).allSatisfy(environment ->
+                assertThat(environment).doesNotContainKey("SERVER_SECRET"));
+        assertThat(processEnvironment.childEnvironments.get(1))
+                .containsEntry("PROVIDER_TOKEN", "request-token");
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironmentTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironmentTest.java
new file mode 100644
index 000000000..abb5bbae7
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliProcessEnvironmentTest.java
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.ai;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.context.properties.bind.Bindable;
+import org.springframework.boot.context.properties.bind.Binder;
+import 
org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CliProcessEnvironmentTest {
+
+    @Test
+    void bindsCommaSeparatedAdditionalNames() {
+        Binder binder = new Binder(new MapConfigurationPropertySource(Map.of(
+                "studio.llm.cli-allowed-environment", 
"CUSTOM_CONFIG,CUSTOM_HOME")));
+
+        LlmProperties properties = binder.bind("studio.llm", 
Bindable.of(LlmProperties.class))
+                .orElseThrow(() -> new AssertionError("LLM properties were not 
bound"));
+
+        
assertThat(properties.getCliAllowedEnvironment()).containsExactly("CUSTOM_CONFIG",
 "CUSTOM_HOME");
+    }
+
+    @Test
+    void keepsOnlyRequiredRuntimeEnvironment() {
+        CliProcessEnvironment policy = new CliProcessEnvironment(List.of());
+        Map<String, String> parent = new LinkedHashMap<>();
+        parent.put("PATH", "/usr/local/bin:/usr/bin");
+        parent.put("HOME", "/home/studio");
+        parent.put("XDG_CONFIG_HOME", "/home/studio/.config");
+        parent.put("TMPDIR", "/tmp/studio");
+        parent.put("LANG", "en_US.UTF-8");
+        parent.put("HTTPS_PROXY", "http://proxy.example:8080";);
+        parent.put("SSL_CERT_FILE", "/etc/ssl/custom.pem");
+        parent.put("SPRING_DATASOURCE_PASSWORD", "database-secret");
+        parent.put("STUDIO_AUTH_ADMIN_PASSWORD", "admin-secret");
+        parent.put("CLOUD_ACCESS_KEY_SECRET", "cloud-secret");
+        parent.put("NODE_OPTIONS", "--require=/tmp/hook.js");
+
+        Map<String, String> child = policy.build(parent, Map.of());
+
+        assertThat(child).containsEntry("PATH", "/usr/local/bin:/usr/bin")
+                .containsEntry("HOME", "/home/studio")
+                .containsEntry("XDG_CONFIG_HOME", "/home/studio/.config")
+                .containsEntry("TMPDIR", "/tmp/studio")
+                .containsEntry("LANG", "en_US.UTF-8")
+                .containsEntry("HTTPS_PROXY", "http://proxy.example:8080";)
+                .containsEntry("SSL_CERT_FILE", "/etc/ssl/custom.pem")
+                .doesNotContainKeys("SPRING_DATASOURCE_PASSWORD", 
"STUDIO_AUTH_ADMIN_PASSWORD",
+                        "CLOUD_ACCESS_KEY_SECRET", "NODE_OPTIONS");
+    }
+
+    @Test
+    void retainsOnlyConfiguredAdditionalNames() {
+        CliProcessEnvironment policy = new CliProcessEnvironment(
+                List.of("CUSTOM_CLI_CONFIG", "  CUSTOM_RUNTIME_HOME  "));
+        Map<String, String> parent = Map.of(
+                "CUSTOM_CLI_CONFIG", "profile-a",
+                "CUSTOM_RUNTIME_HOME", "/opt/provider",
+                "UNRELATED_SECRET", "secret");
+
+        assertThat(policy.build(parent, Map.of()))
+                .containsEntry("CUSTOM_CLI_CONFIG", "profile-a")
+                .containsEntry("CUSTOM_RUNTIME_HOME", "/opt/provider")
+                .doesNotContainKey("UNRELATED_SECRET");
+    }
+
+    @Test
+    void providerEnvironmentOverridesAllowedParentValue() {
+        CliProcessEnvironment policy = new 
CliProcessEnvironment(List.of("ANTHROPIC_AUTH_TOKEN"));
+        Map<String, String> child = policy.build(
+                Map.of("ANTHROPIC_AUTH_TOKEN", "stale-parent-token", "PATH", 
"/usr/bin"),
+                Map.of("ANTHROPIC_AUTH_TOKEN", "request-token", 
"ANTHROPIC_BASE_URL", "https://llm.example";));
+
+        assertThat(child).containsEntry("PATH", "/usr/bin")
+                .containsEntry("ANTHROPIC_AUTH_TOKEN", "request-token")
+                .containsEntry("ANTHROPIC_BASE_URL", "https://llm.example";);
+    }
+
+    @Test
+    void ignoresInvalidConfiguredAndProviderNames() {
+        CliProcessEnvironment policy = new CliProcessEnvironment(
+                List.of("", "   ", "INVALID-NAME", "HAS=VALUE", 
"VALID_EXTRA"));
+        Map<String, String> provider = new LinkedHashMap<>();
+        provider.put("BAD-NAME", "bad");
+        provider.put("GOOD_NAME", "good");
+        provider.put("NULL_VALUE", null);
+
+        Map<String, String> child = policy.build(
+                Map.of("INVALID-NAME", "bad", "HAS=VALUE", "bad", 
"VALID_EXTRA", "kept"), provider);
+
+        assertThat(policy.allowedNames()).contains("VALID_EXTRA")
+                .doesNotContain("INVALID-NAME", "HAS=VALUE");
+        assertThat(child).containsEntry("VALID_EXTRA", "kept")
+                .containsEntry("GOOD_NAME", "good")
+                .doesNotContainKeys("INVALID-NAME", "HAS=VALUE", "BAD-NAME", 
"NULL_VALUE");
+    }
+
+    @Test
+    void applyReplacesTheBuilderEnvironment() {
+        CliProcessEnvironment policy = new CliProcessEnvironment(List.of());
+        ProcessBuilder builder = new ProcessBuilder("sh", "-c", "true");
+        builder.environment().clear();
+        builder.environment().put("PATH", "/usr/bin");
+        builder.environment().put("SERVER_SECRET", "must-not-cross-boundary");
+
+        policy.apply(builder, Map.of("PROVIDER_TOKEN", "provider-secret"));
+
+        assertThat(builder.environment()).containsOnly(
+                Map.entry("PATH", "/usr/bin"),
+                Map.entry("PROVIDER_TOKEN", "provider-secret"));
+    }
+}

Reply via email to