diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index b10e72bd0..86b05ce04 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -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" } @@ -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" @@ -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 @@ -95,6 +95,33 @@ 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. @@ -102,7 +129,7 @@ This example shows version pinning, a pre-installed binary path, a custom model, ```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" @@ -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" @@ -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" @@ -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" @@ -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" diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index bee682d63..56745f67f 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -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 () => { @@ -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 () => { @@ -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: { diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index acfe85387..9013a4be9 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -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" @@ -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" } diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index 08e0c005b..bfa7a357e 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -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*)"] + } + } + } + + 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" + } +} diff --git a/registry/coder/modules/claude-code/scripts/install.sh.tftpl b/registry/coder/modules/claude-code/scripts/install.sh.tftpl index bd142c5d3..1e5fd6316 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh.tftpl +++ b/registry/coder/modules/claude-code/scripts/install.sh.tftpl @@ -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" @@ -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 "--------------------------------" @@ -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..." @@ -158,8 +186,6 @@ function configure_standalone_mode() { echo "Updating existing Claude configuration at $${claude_config}" jq '.autoUpdaterStatus = "disabled" | - .autoModeAccepted = true | - .bypassPermissionsModeAccepted = true | .hasAcknowledgedCostThreshold = true | .hasCompletedOnboarding = true' \ "$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}" @@ -168,8 +194,6 @@ function configure_standalone_mode() { cat > "$${claude_config}" << EOF { "autoUpdaterStatus": "disabled", - "autoModeAccepted": true, - "bypassPermissionsModeAccepted": true, "hasAcknowledgedCostThreshold": true, "hasCompletedOnboarding": true } @@ -189,4 +213,5 @@ EOF install_claude_code_cli setup_claude_configurations +write_managed_settings configure_standalone_mode