Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE

Release with new features and bugfixes:

* https://github.com/devonfw/IDEasy/issues/2068[#2068]: Isolate Claude Code configuration per IDEasy project
* https://github.com/devonfw/IDEasy/issues/1056[#1056]: Improve MSI license display by generating formatted RTF from AsciiDoc
* https://github.com/devonfw/IDEasy/issues/2052[#2052]: Split issue statistics in 2 pie-charts
* https://github.com/devonfw/IDEasy/issues/822[#822] : Added Cwd path limit in shell
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ public interface EnvironmentContext {
*/
EnvironmentContext withPathEntry(Path path);

/**
* Removes (unsets) the specified environment variable in this context so that an inherited/ambient value cannot leak into the launched process. The default
* implementation is a no-op; only contexts backed by a real process environment (see {@link com.devonfw.tools.ide.process.ProcessContextImpl}) actually
* remove the variable. Collector contexts used for shell export intentionally ignore this so the user's interactive shell is never modified.
*
* @param key the name of the environment variable to remove.
* @return this {@link EnvironmentContext} for fluent API calls.
*/
default EnvironmentContext removeEnvVar(String key) {
return this;
}

/**
* @return an empty instance of {@link EnvironmentContext} to prevent {@link NullPointerException}s.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ public ProcessContext withEnvVar(String key, String value) {
return this;
}

@Override
public EnvironmentContext removeEnvVar(String key) {

LOG.trace("Removing process environment variable {}", key);
this.processBuilder.environment().remove(key);
return this;
}

@Override
public ProcessContext withPathEntry(Path path) {

Expand Down
111 changes: 111 additions & 0 deletions cli/src/main/java/com/devonfw/tools/ide/tool/claude/Claude.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,75 @@
package com.devonfw.tools.ide.tool.claude;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;

import com.devonfw.tools.ide.common.Tag;
import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.process.EnvironmentContext;
import com.devonfw.tools.ide.tool.LocalToolCommandlet;
import com.devonfw.tools.ide.tool.ToolCommandlet;
import com.devonfw.tools.ide.tool.ToolInstallation;
import com.devonfw.tools.ide.tool.ToolInstallRequest;

/**
* {@link ToolCommandlet} for <a href="https://github.com/anthropics/claude-code">Claude Code CLI</a>.
*/
public class Claude extends LocalToolCommandlet {

/** Name of the environment variable that relocates the entire Claude configuration directory. */
static final String CLAUDE_CONFIG_DIR = "CLAUDE_CONFIG_DIR";

/** Sub-directory of {@code conf} holding the isolated Claude configuration. */
static final String CONFIG_FOLDER = "claude";

/** Content of the seeded {@code README.md} explaining that the user owns {@code settings.json} and which scrubbed variables to declare there. */
private static final String README_CONTENT = """
# Isolated Claude configuration (managed location, content owned by you)

This directory is your project-local CLAUDE_CONFIG_DIR. IDEasy points Claude here so this
project's settings, credentials, MCP servers and history stay separate from other projects.

Put ALL provider/auth configuration in `settings.json` -> `env`. IDEasy removes the following
ambient variables before launching Claude, so they must be declared here if you need them:
ANTHROPIC_*, CLAUDE_CODE_USE_BEDROCK/VERTEX/FOUNDRY, CLAUDE_CODE_OAUTH_TOKEN,
AWS_PROFILE, AWS_REGION, AWS_*_KEY*, AWS_SESSION_TOKEN, AWS_BEARER_TOKEN_BEDROCK.

Example (AWS Bedrock):
{
"model": "us.anthropic.claude-opus-4-8-v1",
"env": {
"CLAUDE_CODE_USE_BEDROCK": "1",
"AWS_PROFILE": "my-project-profile",
"AWS_REGION": "eu-central-1"
}
}

Example (custom / sovereign endpoint):
{
"env": {
"ANTHROPIC_BASE_URL": "https://your-endpoint.example",
"ANTHROPIC_AUTH_TOKEN": "..."
}
}
""";

/**
* Provider/auth environment variables removed from the launched Claude process so an ambient/leaked value cannot override the per-project configuration. The
* isolated {@code settings.json} env block is the single source of truth.
*/
static final List<String> SCRUB_VARS = List.of(
"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL",
"ANTHROPIC_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", "ANTHROPIC_CUSTOM_HEADERS",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have configured ANTROPIC_MODEL and some other variables in my conf/ide.properties.
Does it really make sense to remove them all with no way to circumvent?

@hohwille hohwille Jun 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a project it would even make sense to configure variables like ANTROPIC_MODEL or BEDROCK_MODEL_ID in settings/ide.properties to share such settings across the team.
I am not convinced that nuking such variables generally is a good idea.
Maybe we should remove them only if they are not comming from ide.properties but are inherited from System environment variables?

"ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_BEDROCK_BASE_URL",
"CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY",
"CLAUDE_CODE_OAUTH_TOKEN",
"AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION",
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN",
"AWS_BEARER_TOKEN_BEDROCK");

/**
* The constructor.
*
Expand All @@ -27,4 +85,57 @@ public String getToolHelpArguments() {

return "--help";
}

/**
* @return the {@link Path} to the isolated Claude configuration directory ({@code $IDE_HOME/conf/claude}) or {@code null} if no {@code IDE_HOME} is present.
*/
Path getClaudeConfigDir() {

Path confPath = this.context.getConfPath();
if (confPath == null) {
return null;
}
return confPath.resolve(CONFIG_FOLDER);
}

@Override
public void setEnvironment(EnvironmentContext environmentContext, ToolInstallation toolInstallation, boolean additionalInstallation) {

super.setEnvironment(environmentContext, toolInstallation, additionalInstallation);
Path claudeConfigDir = getClaudeConfigDir();
if (claudeConfigDir == null) {
return;
}
environmentContext.withEnvVar(CLAUDE_CONFIG_DIR, claudeConfigDir.toString());
for (String name : SCRUB_VARS) {
environmentContext.removeEnvVar(name);
}
}

@Override
protected void postInstall(ToolInstallRequest request) {

super.postInstall(request);
seedConfigSkeleton();
}

/**
* Creates the isolated config directory with a minimal valid {@code settings.json} and a {@code README.md} if they do not exist yet. Existing files are never
* modified - the user owns the content.
*/
private void seedConfigSkeleton() {

Path claudeConfigDir = getClaudeConfigDir();
if (claudeConfigDir == null) {
return;
}
Path settings = claudeConfigDir.resolve("settings.json");
if (!Files.exists(settings)) {
this.context.getFileAccess().writeFileContent("{\n \"env\": {}\n}\n", settings, true);
}
Path readme = claudeConfigDir.resolve("README.md");
if (!Files.exists(readme)) {
this.context.getFileAccess().writeFileContent(README_CONTENT, readme, true);
}
}
}
8 changes: 8 additions & 0 deletions cli/src/main/package/functions
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ function icd() {
return
}

function claude() {
if [ -n "${IDE_HOME}" ]; then
ide claude "$@"
else
command claude "$@"
fi
}

Comment on lines +126 to +133

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice idea to create such shim.
We once had some situation where a bug in IDEasy prevented to call a tool from IDEasy.
Such shim would take away the option to bypass IDEasy then for average users (who would not get to the idea to unload this function).
So - why would we need this or what would be the problem if we do not add this?
IMHO before running claude, we need to ide install claude what will already prepare the settings and set the variables. Surely if I switch projects with cd and not icd and do not also call some ide command before running the claude command directly, I might be connected to the wrong account.
Therefore I like your idea of this shim...

I am just thinking loud and challenging this so we balances pros and cons and take a clear decision.

_ide_create_project()
{
local found_create=false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,23 @@ private IdeLogLevel convertToIdeLogLevel(ProcessErrorHandling processErrorHandli
};
}

@Test
void removeEnvVarShouldRemoveVariableFromProcessEnvironment() {

// arrange
java.util.Map<String, String> env = new java.util.HashMap<>();
env.put("ANTHROPIC_API_KEY", "leaked-value");
env.put("KEEP_ME", "stays");
when(this.mockProcessBuilder.environment()).thenReturn(env);

// act
this.processContextUnderTest.removeEnvVar("ANTHROPIC_API_KEY");

// assert
assertThat(env).doesNotContainKey("ANTHROPIC_API_KEY");
assertThat(env).containsEntry("KEEP_ME", "stays");
}

@Test
void testModifyArgumentsOnBackgroundProcessWithBackgroundMode() throws Exception {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package com.devonfw.tools.ide.tool.claude;

import java.nio.file.Files;
import java.nio.file.Path;

import org.junit.jupiter.api.Test;

import com.devonfw.tools.ide.context.AbstractIdeContextTest;
import com.devonfw.tools.ide.context.IdeTestContext;
import com.devonfw.tools.ide.tool.ToolInstallation;
import com.devonfw.tools.ide.version.VersionIdentifier;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;

Expand Down Expand Up @@ -45,6 +50,81 @@ void testClaudeRunInstallsAndPassesArguments(WireMockRuntimeInfo wireMockRuntime
assertThat(context).logAtInfo().hasMessage("claude hello world");
}

@Test
void testSetEnvironmentIsolatesConfigDirAndScrubsLeakingVars() {

// arrange
IdeTestContext context = newContext(PROJECT_CLAUDE, null, false);
Claude claude = new Claude(context);
Path dummy = context.getSoftwarePath().resolve("claude");
ToolInstallation installation = new ToolInstallation(dummy, dummy, dummy, VersionIdentifier.of(CLAUDE_VERSION), false);
RecordingEnvironmentContext ec = new RecordingEnvironmentContext();

// act
claude.setEnvironment(ec, installation, false);

// assert
Path expectedConfigDir = context.getConfPath().resolve("claude");
assertThat(ec.set).containsEntry("CLAUDE_CONFIG_DIR", expectedConfigDir.toString());
assertThat(ec.removed).contains("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL",
"CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_OAUTH_TOKEN", "AWS_PROFILE", "AWS_REGION",
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_BEARER_TOKEN_BEDROCK");
assertThat(ec.removed).doesNotContain("CLAUDE_CONFIG_DIR");
}

@Test
void testInstallSeedsSettingsSkeletonWhenAbsent(WireMockRuntimeInfo wireMockRuntimeInfo) {

// arrange
IdeTestContext context = newContext(PROJECT_CLAUDE, wireMockRuntimeInfo);
Claude claude = new Claude(context);

// act
claude.install();

// assert
Path settings = context.getConfPath().resolve("claude/settings.json");
Path readme = context.getConfPath().resolve("claude/README.md");
assertThat(settings).exists().content().contains("\"env\"");
assertThat(readme).exists();
}

@Test
void testInstallDoesNotOverwriteExistingSettings(WireMockRuntimeInfo wireMockRuntimeInfo) throws Exception {

// arrange
IdeTestContext context = newContext(PROJECT_CLAUDE, wireMockRuntimeInfo);
Path settings = context.getConfPath().resolve("claude/settings.json");
Files.createDirectories(settings.getParent());
String userContent = "{\n \"env\": { \"CLAUDE_CODE_USE_BEDROCK\": \"1\" }\n}\n";
Files.writeString(settings, userContent);
Claude claude = new Claude(context);

// act
claude.install();

// assert
assertThat(settings).content().isEqualTo(userContent);
}

@Test
void testRunExportsIsolatedConfigDirToProcess(WireMockRuntimeInfo wireMockRuntimeInfo) {

// arrange
IdeTestContext context = newContext(PROJECT_CLAUDE, wireMockRuntimeInfo);
Claude claude = new Claude(context);
claude.arguments.addValue("hello");
claude.arguments.addValue("world");

// act
claude.run();

// assert
String expectedConfigDir = context.getConfPath().resolve("claude").toString();
assertThat(context).logAtInfo().hasMessage("claude hello world");
assertThat(context).logAtInfo().hasMessageContaining("CLAUDE_CONFIG_DIR=" + expectedConfigDir);
}

private void assertInstalled(IdeTestContext context) {

assertThat(context.getSoftwarePath().resolve("claude/.ide.software.version")).exists().hasContent(CLAUDE_VERSION);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.devonfw.tools.ide.tool.claude;

import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import com.devonfw.tools.ide.process.EnvironmentContext;

/**
* Test double for {@link EnvironmentContext} that records every variable set or removed.
*/
public class RecordingEnvironmentContext implements EnvironmentContext {

/** Variables set via {@link #withEnvVar(String, String)}. */
public final Map<String, String> set = new HashMap<>();

/** Variables removed via {@link #removeEnvVar(String)}. */
public final Set<String> removed = new HashSet<>();

@Override
public EnvironmentContext withEnvVar(String key, String value) {
this.set.put(key, value);
return this;
}

@Override
public EnvironmentContext withPathEntry(Path path) {
return this;
}

@Override
public EnvironmentContext removeEnvVar(String key) {
this.removed.add(key);
return this;
}
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
#!/usr/bin/env bash
echo "claude $*"
echo "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}"
20 changes: 20 additions & 0 deletions documentation/ai.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,23 @@ However, you can also map specific subfolders:

So if your git repo would contain the hooks for GitHub Copilot in the folder `myhooks/github` those will be linked to appear as `.github/hooks` in your workspace.
We hope this gives you ultimate flexibility to solve all problems you may have.

== Isolated Claude configuration

When you run multiple Claude Code configurations (e.g. different AWS Bedrock accounts, a sovereign endpoint, a company-wide instance), they normally interfere through the shared `~/.claude` home folder and through leaked environment variables.

IDEasy isolates Claude per project. Running `ide claude` (or simply `claude` while an IDEasy project is active):

* sets `CLAUDE_CONFIG_DIR` to `$IDE_HOME/conf/claude`, so settings, credentials, MCP servers and history are stored per project;
* removes leaking provider/auth variables (`ANTHROPIC_*`, `CLAUDE_CODE_USE_BEDROCK`/`VERTEX`/`FOUNDRY`, `CLAUDE_CODE_OAUTH_TOKEN`, `AWS_PROFILE`, `AWS_REGION`, `AWS_*_KEY*`, `AWS_SESSION_TOKEN`, `AWS_BEARER_TOKEN_BEDROCK`) from the launched process;
* seeds a user-owned `conf/claude/settings.json` skeleton on first install.

Put *all* provider/auth configuration in `conf/claude/settings.json` under the `env` block (see the generated `conf/claude/README.md` for Bedrock and custom-endpoint examples).
Because scrubbed variables such as `AWS_PROFILE` are removed from the process, declare them there rather than relying on your shell.
Comment on lines +100 to +111

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add this specificity for Claude here?
IMHO we should explain the general concept and link to
https://github.com/devonfw/IDEasy/blob/main/documentation/sandbox.adoc
Then we can add such text as example.
But for Github Copilot I expect the same isolation - if not the case, we also need to file and fix a bug.
For AWS CLI we have it working and also for many other such tools.


[NOTE]
====
Known limitations: on macOS, Claude.ai _subscription_ login credentials are stored in the system Keychain, which `CLAUDE_CONFIG_DIR` does not relocate (use Bedrock/API-token auth to isolate on macOS; not relevant for Bedrock setups).
An enterprise `managed-settings.json` overrides all projects by design.
A bare `claude` run while no IDEasy project is active is not scrubbed - keep provider credentials out of your shell rc files.
====
Loading