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
43 changes: 35 additions & 8 deletions registry/coder/modules/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agent
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
anthropic_api_key = "xxxx-xxxxx-xxxx"
}
Expand Down Expand Up @@ -47,7 +47,7 @@ locals {

module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = local.claude_workdir
anthropic_api_key = "xxxx-xxxxx-xxxx"
Expand Down Expand Up @@ -78,7 +78,7 @@ resource "coder_app" "claude" {
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_ai_gateway = true
Expand All @@ -95,14 +95,41 @@ Claude Code then routes API requests through Coder's AI Gateway instead of direc
> [!CAUTION]
> `enable_ai_gateway = true` is mutually exclusive with `anthropic_api_key` and `claude_code_oauth_token`. Setting any of them together fails at plan time.

### Enterprise policy via managed settings

The `managed_settings` input writes a policy file to `/etc/claude-code/managed-settings.d/10-coder.json` inside the workspace. Claude Code reads this directory at startup with the highest configuration precedence, so users cannot override these values in their own `~/.claude/settings.json`. This is a local file mechanism and works with any inference backend (Anthropic API, AWS Bedrock, Google Vertex AI, or AI Gateway).

```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"

managed_settings = {
permissions = {
defaultMode = "acceptEdits"
disableBypassPermissionsMode = "disable"
deny = ["Bash(curl:*)", "Bash(wget:*)", "WebFetch"]
}
env = {
DISABLE_TELEMETRY = "0"
}
}
}
```

See the [Claude Code settings reference](https://docs.anthropic.com/en/docs/claude-code/settings) for the full schema. Common keys: `permissions` (`defaultMode`, `allow`, `deny`, `disableBypassPermissionsMode`, `additionalDirectories`), `env`, `model`, `apiKeyHelper`, `hooks`, `cleanupPeriodDays`.

### Advanced Configuration

This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers.

```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"

Expand Down Expand Up @@ -166,7 +193,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
Expand Down Expand Up @@ -252,7 +279,7 @@ resource "coder_env" "bedrock_api_key" {

module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
Expand Down Expand Up @@ -309,7 +336,7 @@ resource "coder_env" "google_application_credentials" {

module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
Expand Down Expand Up @@ -350,7 +377,7 @@ The module automatically tags every span and metric with `coder.workspace_id`, `
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.1.0"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
Expand Down
50 changes: 48 additions & 2 deletions registry/coder/modules/claude-code/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,10 +382,13 @@ describe("claude-code", async () => {
const parsed = JSON.parse(claudeConfig);
expect(parsed.autoUpdaterStatus).toBe("disabled");
expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
expect(parsed.hasAcknowledgedCostThreshold).toBe(true);
expect(parsed.projects[workdir].hasCompletedProjectOnboarding).toBe(true);
expect(parsed.projects[workdir].hasTrustDialogAccepted).toBe(true);
// Permission posture is delivered via /etc/claude-code/managed-settings.d/,
// not user-writable ~/.claude.json acceptance flags.
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
expect(parsed.autoModeAccepted).toBeUndefined();
});

test("standalone-mode-with-oauth-token", async () => {
Expand Down Expand Up @@ -413,7 +416,7 @@ describe("claude-code", async () => {
);
const parsed = JSON.parse(claudeConfig);
expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
});

test("standalone-mode-no-auth", async () => {
Expand All @@ -436,6 +439,49 @@ describe("claude-code", async () => {
expect(resp.stdout.trim()).toBe("ABSENT");
});

test("claude-managed-settings-written", async () => {
const { id, scripts } = await setup({
moduleVariables: {
managed_settings: JSON.stringify({
permissions: {
defaultMode: "acceptEdits",
disableBypassPermissionsMode: "disable",
deny: ["Bash(rm -rf*)"],
},
}),
},
});
await runScripts(id, scripts);

const policy = await execContainer(id, [
"bash",
"-c",
"cat /etc/claude-code/managed-settings.d/10-coder.json",
]);
expect(policy.exitCode).toBe(0);
expect(policy.stdout).toContain('"defaultMode":"acceptEdits"');
expect(policy.stdout).toContain('"disableBypassPermissionsMode":"disable"');
expect(policy.stdout).toContain('"deny":["Bash(rm -rf*)"]');

const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("Wrote Claude Code managed settings");
});

test("claude-managed-settings-not-set", async () => {
const { id, scripts } = await setup();
await runScripts(id, scripts);

const resp = await execContainer(id, [
"bash",
"-c",
"test -e /etc/claude-code/managed-settings.d/10-coder.json && echo EXISTS || echo ABSENT",
]);
expect(resp.stdout.trim()).toBe("ABSENT");
});

test("telemetry-otel", async () => {
const { coderEnvVars } = await setup({
moduleVariables: {
Expand Down
7 changes: 7 additions & 0 deletions registry/coder/modules/claude-code/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ variable "claude_binary_path" {
}
}

variable "managed_settings" {
type = any
description = "Policy settings written to /etc/claude-code/managed-settings.d/10-coder.json. Highest-precedence client config; works with any inference backend (Anthropic API, Bedrock, Vertex, AI Gateway). See https://docs.anthropic.com/en/docs/claude-code/settings for the schema."
default = null
}

variable "enable_ai_gateway" {
type = bool
description = "Use AI Gateway for Claude Code. https://coder.com/docs/ai-coder/ai-gateway"
Expand Down Expand Up @@ -237,6 +243,7 @@ locals {
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path))
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : ""
})
module_dir_name = ".coder-modules/coder/claude-code"
}
Expand Down
44 changes: 44 additions & 0 deletions registry/coder/modules/claude-code/main.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,47 @@ run "test_workdir_optional" {
error_message = "workdir should default to null when omitted"
}
}

run "test_managed_settings" {
command = plan

variables {
agent_id = "test-agent-managed-settings"
workdir = "/home/coder/project"
managed_settings = {
permissions = {
defaultMode = "acceptEdits"
disableBypassPermissionsMode = "disable"
deny = ["Bash(rm -rf*)"]
Comment on lines +293 to +297
Copy link
Copy Markdown
Collaborator

@DevelopmentCats DevelopmentCats Apr 28, 2026

Choose a reason for hiding this comment

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

@matifali for reference this is how you would pass similar behavior through managed settings rather than pre-seeding it.

So essentially someone could set defaultMode=auto and same with the rest for claude-code running for tasks and this would work over even the settings.json

}
}
}

assert {
condition = var.managed_settings.permissions.defaultMode == "acceptEdits"
error_message = "managed_settings should accept the permissions object"
}

assert {
condition = strcontains(local.install_script, "/etc/claude-code/managed-settings.d")
error_message = "install script should reference the managed-settings.d drop-in directory"
}

assert {
condition = strcontains(local.install_script, base64encode(jsonencode(var.managed_settings)))
error_message = "install script should embed the base64-encoded managed_settings JSON"
}
}

run "test_managed_settings_default_null" {
command = plan

variables {
agent_id = "test-agent-managed-settings-default"
}

assert {
condition = var.managed_settings == null
error_message = "managed_settings should default to null when omitted"
}
}
33 changes: 29 additions & 4 deletions registry/coder/modules/claude-code/scripts/install.sh.tftpl
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n '${ARG_MCP_CONFIG_REMOTE_PATH}' | base64 -d)
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d)

export PATH="$${ARG_CLAUDE_BINARY_PATH}:$PATH"

Expand All @@ -29,6 +30,7 @@ printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$${ARG_CLAUDE_BINARY_PATH}"
printf "ARG_MCP: %s\n" "$${ARG_MCP}"
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}"
printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
printf "ARG_MANAGED_SETTINGS_JSON: %s\n" "$${ARG_MANAGED_SETTINGS_JSON}"

echo "--------------------------------"

Expand Down Expand Up @@ -144,6 +146,32 @@ function setup_claude_configurations() {

}

function write_managed_settings() {
if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then
return
fi

local dropin_dir="/etc/claude-code/managed-settings.d"
local target="$${dropin_dir}/10-coder.json"

if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq empty 2> /dev/null; then
echo "Warning: managed_settings is not valid JSON, skipping policy write"
return
fi

if command_exists sudo; then
sudo mkdir -p "$${dropin_dir}"
echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null
sudo chmod 0644 "$${target}"
else
mkdir -p "$${dropin_dir}"
echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}"
chmod 0644 "$${target}"
fi

echo "Wrote Claude Code managed settings to $${target}"
}

function configure_standalone_mode() {
echo "Configuring Claude Code for standalone mode..."

Expand All @@ -158,8 +186,6 @@ function configure_standalone_mode() {
echo "Updating existing Claude configuration at $${claude_config}"

jq '.autoUpdaterStatus = "disabled" |
.autoModeAccepted = true |
.bypassPermissionsModeAccepted = true |
Comment on lines -161 to -162
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.

Why did we remove this? AFAIK, these were needed for skipping the welcome wizard.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the reason they are removing them is because you are able to set these same behaviors through managed settings. Which is probably better than us pre-seeding it for all users in this hacky unobservable way.

Although I think we should probably test this a bit more before committing since we want to make sure this won't introduce too big of a behavioral shift in the module for users who have this running in a task workflow.

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.

This serves the standalone mode, which we need to set anyway when a user provides a workdir. I don't see how that would work if we remove this. I am all good if we can do it more systematically. But if a user starts Claude via coder_script in a specific workdir, they should not be greeted with a config wizard.

Copy link
Copy Markdown
Collaborator

@DevelopmentCats DevelopmentCats Apr 28, 2026

Choose a reason for hiding this comment

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

You know I think you are right here. These are mostly just for the dialogue boxes unless they get suppressed now somehow.

.hasAcknowledgedCostThreshold = true |
.hasCompletedOnboarding = true' \
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
Expand All @@ -168,8 +194,6 @@ function configure_standalone_mode() {
cat > "$${claude_config}" << EOF
{
"autoUpdaterStatus": "disabled",
"autoModeAccepted": true,
"bypassPermissionsModeAccepted": true,
"hasAcknowledgedCostThreshold": true,
"hasCompletedOnboarding": true
}
Expand All @@ -189,4 +213,5 @@ EOF

install_claude_code_cli
setup_claude_configurations
write_managed_settings
configure_standalone_mode
Loading