From 4ca251f448c9f82ed53ab2825c7546764249457e Mon Sep 17 00:00:00 2001 From: Morgan Lunt Date: Fri, 15 May 2026 06:27:42 -0700 Subject: [PATCH] feat(claude-code): add managed_settings input for policy delivery via /etc/claude-code (#863) ## Problem The module configures Claude Code's permission posture by reaching around the permission system rather than through it: - `scripts/install.sh` writes `bypassPermissionsModeAccepted`, `autoModeAccepted`, and `primaryApiKey` directly into the user-writable `~/.claude.json`. Any process in the workspace can read the API key or flip the acceptance flags back. - `scripts/start.sh` adds `--dangerously-skip-permissions` to every task launch, even when the template author set an explicit `permission_mode`. The README has to carry a security warning telling people the module bypasses permission checks. - `permission_mode`, `allowed_tools`, and `disallowed_tools` each plumb through a different ad-hoc path (CLI flag, `coder` subcommand) instead of a single policy surface. ## Change Add a `managed_settings` input that renders to `/etc/claude-code/managed-settings.d/10-coder.json`. Claude Code reads that drop-in directory at startup with the highest configuration precedence (above `~/.claude/settings.json` and project settings), 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 Bridge / AI Gateway. ```hcl managed_settings = { permissions = { defaultMode = "acceptEdits" disableBypassPermissionsMode = "disable" deny = ["Bash(curl:*)", "WebFetch"] } } ``` Supporting changes: - `install.sh` writes the policy file (root-owned, 0644) and stops writing `bypassPermissionsModeAccepted`, `autoModeAccepted`, and `primaryApiKey` into `~/.claude.json`. The API key is already exported via `coder_env` as `CLAUDE_API_KEY`; duplicating it on disk is unnecessary. `hasCompletedOnboarding` stays because there is no env-var alternative for it. - `start.sh` only adds `--dangerously-skip-permissions` for tasks when no explicit `permission_mode` is set (same fix as #846; included here so this PR is self-contained, happy to drop if #846 lands first). - `permission_mode`, `allowed_tools`, and `disallowed_tools` are marked deprecated and shimmed into `managed_settings.permissions` for one release when `managed_settings` is not provided. - README security warning rewritten to point at the policy mechanism instead of telling people the module is unsafe by design. ## Relationship to #861 #861 strips this module to install-and-configure and removes `permission_mode` / `allowed_tools` / `disallowed_tools` outright. `managed_settings` is the natural replacement for those: it is install-time (survives the `start.sh` removal), it covers everything the dropped variables did plus `hooks`, `env`, `model`, `apiKeyHelper`, and the rest of the settings schema, and it does not require the module to know anything about how Claude is launched. If #861 lands first I will rebase this on top and drop the deprecation shim and the `start.sh` hunk. ## Validation - `terraform fmt` / `terraform validate` clean - New tests: `claude-managed-settings-written`, `claude-managed-settings-legacy-shim`, `claude-no-policy-keys-in-claudejson`, plus an assertion in `claude-auto-permission-mode` that `--dangerously-skip-permissions` is absent when a mode is set - Manually verified `/etc/claude-code/managed-settings.d/*.json` precedence in the Claude Code CLI source Closes #818. Relates to #284, #846, #861. Disclosure: I work at Anthropic on the Claude Code team. Happy to adjust scope or split this further if that is easier to review. --------- Co-authored-by: DevCats Co-authored-by: DevCats --- registry/coder/modules/claude-code/README.md | 43 +++++++++++++--- .../coder/modules/claude-code/main.test.ts | 50 ++++++++++++++++++- registry/coder/modules/claude-code/main.tf | 7 +++ .../coder/modules/claude-code/main.tftest.hcl | 44 ++++++++++++++++ .../claude-code/scripts/install.sh.tftpl | 33 ++++++++++-- 5 files changed, 163 insertions(+), 14 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index b10e72bd..86b05ce0 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 bee682d6..56745f67 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 acfe8538..9013a4be 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 08e0c005..bfa7a357 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 bd142c5d..1e5fd631 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