From 4b83931dd3b1008019cd8a7d37e7c8dccbe784f2 Mon Sep 17 00:00:00 2001 From: Morgan Westlee Lunt Date: Mon, 27 Apr 2026 23:24:54 +0000 Subject: [PATCH 1/3] feat(claude-code): add managed_settings input for policy delivery via /etc/claude-code Re-authored on top of the post-#861 install-only module. Adds a managed_settings variable that the install script writes to /etc/claude-code/managed-settings.d/10-coder.json. Claude Code reads this drop-in directory at startup with the highest configuration precedence, so template authors get an admin-controlled policy file that users inside the workspace cannot override. The mechanism is a local file read with no API call, so it works identically for the Anthropic API, AWS Bedrock, Google Vertex AI, and AI Gateway. Compared to the original PR against v4.x, this drops the deprecation shim for permission_mode/allowed_tools/disallowed_tools (those vars are gone in v5) and the start.sh changes (start.sh is gone). The ~/.claude.json policy-key removal is also dropped from this PR scope since the surrounding configure_standalone_mode logic changed substantially in #861; can revisit separately if wanted. --- registry/coder/modules/claude-code/README.md | 41 ++++++++++++++--- .../coder/modules/claude-code/main.test.ts | 43 ++++++++++++++++++ registry/coder/modules/claude-code/main.tf | 7 +++ .../coder/modules/claude-code/main.tftest.hcl | 44 +++++++++++++++++++ .../claude-code/scripts/install.sh.tftpl | 29 ++++++++++++ 5 files changed, 157 insertions(+), 7 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index d3264256f..57ed27cd8 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.0.0" + version = "5.1.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.0.0" + version = "5.1.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.0.0" + version = "5.1.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.1.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.0.0" + version = "5.1.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.0.0" + version = "5.1.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.0.0" + version = "5.1.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.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 6b1367551..361ebcac0 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -435,4 +435,47 @@ 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"); + }); }); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 346930db9..306589cb1 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" @@ -173,6 +179,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..2c8774ddb 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..." @@ -189,4 +217,5 @@ EOF install_claude_code_cli setup_claude_configurations +write_managed_settings configure_standalone_mode From 6745a0b185f59a22d54dd3534d1b5ecdb917865a Mon Sep 17 00:00:00 2001 From: Morgan Westlee Lunt Date: Mon, 27 Apr 2026 23:34:27 +0000 Subject: [PATCH 2/3] stop writing autoModeAccepted/bypassPermissionsModeAccepted to ~/.claude.json These flags pre-accept the permission-mode confirmation dialogs in a user-writable file. Permission posture is the managed_settings input's job (delivered via /etc/claude-code/managed-settings.d/, root-owned, not user-overridable). Onboarding-bypass keys (hasCompletedOnboarding, hasAcknowledgedCostThreshold, project trust) stay since there is no managed-settings equivalent for those. --- registry/coder/modules/claude-code/main.test.ts | 7 +++++-- .../coder/modules/claude-code/scripts/install.sh.tftpl | 4 ---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 361ebcac0..d2907673b 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 () => { diff --git a/registry/coder/modules/claude-code/scripts/install.sh.tftpl b/registry/coder/modules/claude-code/scripts/install.sh.tftpl index 2c8774ddb..1e5fd6316 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh.tftpl +++ b/registry/coder/modules/claude-code/scripts/install.sh.tftpl @@ -186,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}" @@ -196,8 +194,6 @@ function configure_standalone_mode() { cat > "$${claude_config}" << EOF { "autoUpdaterStatus": "disabled", - "autoModeAccepted": true, - "bypassPermissionsModeAccepted": true, "hasAcknowledgedCostThreshold": true, "hasCompletedOnboarding": true } From 3b399bba415277f277c3f36a9a032445864549d6 Mon Sep 17 00:00:00 2001 From: DevCats Date: Wed, 29 Apr 2026 19:02:01 +0000 Subject: [PATCH 3/3] docs(claude-code): bump version references in README to 5.2.0 --- registry/coder/modules/claude-code/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 547bd013d..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 @@ -102,7 +102,7 @@ The `managed_settings` input writes a policy file to `/etc/claude-code/managed-s ```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" @@ -129,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" @@ -193,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" @@ -279,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" @@ -336,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" @@ -377,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"