From ef8cf7142d27edf68f8764b3291673fb17bb9483 Mon Sep 17 00:00:00 2001 From: Morgan Westlee Lunt Date: Wed, 22 Apr 2026 20:20:35 +0000 Subject: [PATCH 1/3] feat(claude-code): add managed_settings input for /etc/claude-code policy delivery The module currently configures permission posture by writing bypassPermissionsModeAccepted, autoModeAccepted, and primaryApiKey directly into the user-writable ~/.claude.json, and forces --dangerously-skip-permissions on every task launch regardless of the configured permission_mode. Both bypass Claude Code's permission system rather than configuring it. This adds a managed_settings input that renders to /etc/claude-code/managed-settings.d/10-coder.json, the sanctioned drop-in directory Claude Code reads at highest precedence. The file is root-owned so users cannot override it from inside the workspace, and the mechanism is purely client-side so it works with any inference backend (Anthropic API, Bedrock, Vertex, AI Gateway). permission_mode, allowed_tools, and disallowed_tools are deprecated in favor of managed_settings.permissions and are shimmed into the policy file for one release when managed_settings is not set. start.sh now only adds --dangerously-skip-permissions for tasks when no explicit permission_mode is configured (same approach as #846), and install.sh no longer writes permission-acceptance flags or the API key into ~/.claude.json. --- registry/coder/modules/claude-code/README.md | 31 ++++++++- .../coder/modules/claude-code/main.test.ts | 66 +++++++++++++++++++ registry/coder/modules/claude-code/main.tf | 29 +++++++- .../modules/claude-code/scripts/install.sh | 37 +++++++++-- .../modules/claude-code/scripts/start.sh | 11 +++- 5 files changed, 161 insertions(+), 13 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 48b291bb0..21180a813 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -21,7 +21,7 @@ module "claude-code" { ``` > [!WARNING] -> **Security Notice**: This module uses the `--dangerously-skip-permissions` flag when running Claude Code tasks. This flag bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as the user running it. Use this module _only_ in trusted environments and be aware of the security implications. +> **Security Notice**: When no `permission_mode` or `managed_settings` policy is configured, this module passes `--dangerously-skip-permissions` to Claude Code tasks for backward compatibility. That flag bypasses all permission checks. For production use, set `managed_settings.permissions.defaultMode` (see [Enterprise policy via managed settings](#enterprise-policy-via-managed-settings)) so Claude Code runs under an explicit, admin-controlled permission posture instead. > [!NOTE] > By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details. @@ -32,6 +32,35 @@ module "claude-code" { - You can get the API key from the [Anthropic Console](https://console.anthropic.com/dashboard). - You can get the Session Token using the `claude setup-token` command. This is a long-lived authentication token (requires Claude subscription) +### 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 Bridge / AI Gateway). + +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "4.9.2" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + + 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 (`permissions`, `env`, `hooks`, `apiKeyHelper`, `model`, and more). + +> [!NOTE] +> The legacy `permission_mode`, `allowed_tools`, and `disallowed_tools` variables are deprecated in favor of `managed_settings.permissions`. For one release they are automatically mapped into the policy file when `managed_settings` is not set. + ### Session Resumption Behavior By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false` diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index b01e88327..77227cc15 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -198,6 +198,72 @@ describe("claude-code", async () => { "cat /home/coder/.claude-module/agentapi-start.log", ]); expect(startLog.stdout).toContain(`--permission-mode ${mode}`); + // With an explicit permission_mode, tasks must not also force + // --dangerously-skip-permissions on top of it. + expect(startLog.stdout).not.toContain("--dangerously-skip-permissions"); + }); + + test("claude-managed-settings-written", async () => { + const { id } = await setup({ + moduleVariables: { + managed_settings: JSON.stringify({ + permissions: { + defaultMode: "acceptEdits", + deny: ["Bash(rm -rf*)"], + }, + }), + }, + }); + await execModuleScript(id); + + 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('"deny":["Bash(rm -rf*)"]'); + + const installLog = await readFileContainer( + id, + "/home/coder/.claude-module/install.log", + ); + expect(installLog).toContain("Wrote Claude Code managed settings"); + }); + + test("claude-managed-settings-legacy-shim", async () => { + const { id } = await setup({ + moduleVariables: { + permission_mode: "plan", + disallowed_tools: "Bash(curl:*),WebFetch", + }, + }); + await execModuleScript(id); + + 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":"plan"'); + expect(policy.stdout).toContain('"deny":["Bash(curl:*)","WebFetch"]'); + }); + + test("claude-no-policy-keys-in-claudejson", async () => { + const { id } = await setup({ + moduleVariables: { + report_tasks: "false", + claude_api_key: "sk-test-standalone", + }, + }); + await execModuleScript(id); + + const cfg = await readFileContainer(id, "/home/coder/.claude.json"); + expect(cfg).toContain("hasCompletedOnboarding"); + expect(cfg).not.toContain("bypassPermissionsModeAccepted"); + expect(cfg).not.toContain("primaryApiKey"); }); test("claude-model", async () => { diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index db234c052..7ce1ddb3d 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -158,7 +158,7 @@ variable "dangerously_skip_permissions" { variable "permission_mode" { type = string - description = "Permission mode for the cli, check https://docs.anthropic.com/en/docs/claude-code/iam#permission-modes" + description = "Deprecated: use managed_settings.permissions.defaultMode instead. Permission mode for the cli, check https://docs.anthropic.com/en/docs/claude-code/iam#permission-modes" default = "" validation { condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode) @@ -180,15 +180,20 @@ variable "mcp_config_remote_path" { variable "allowed_tools" { type = string - description = "A list of tools that should be allowed without prompting the user for permission, in addition to settings.json files." + description = "Deprecated: use managed_settings.permissions.allow instead. A comma-separated list of tools that should be allowed without prompting the user for permission." default = "" } variable "disallowed_tools" { type = string - description = "A list of tools that should be disallowed without prompting the user for permission, in addition to settings.json files." + description = "Deprecated: use managed_settings.permissions.deny instead. A comma-separated list of tools that should be disallowed without prompting the user for permission." default = "" +} +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 "claude_code_oauth_token" { @@ -334,6 +339,23 @@ locals { coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "") claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key + # Deprecation shim: map legacy permission vars into managed-settings shape + # when managed_settings is not provided. Removed once the legacy vars are dropped. + legacy_permissions = merge( + var.permission_mode != "" ? { defaultMode = var.permission_mode } : {}, + var.allowed_tools != "" ? { allow = [for t in split(",", var.allowed_tools) : trimspace(t)] } : {}, + var.disallowed_tools != "" ? { deny = [for t in split(",", var.disallowed_tools) : trimspace(t)] } : {}, + ) + managed_settings_json = ( + var.managed_settings != null + ? jsonencode(var.managed_settings) + : ( + length(local.legacy_permissions) > 0 + ? jsonencode({ permissions = local.legacy_permissions }) + : "" + ) + ) + # Required prompts for the module to properly report task status to Coder report_tasks_system_prompt = <<-EOT -- Tool Selection -- @@ -431,6 +453,7 @@ module "agentapi" { ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \ ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \ ARG_PERMISSION_MODE='${var.permission_mode}' \ + ARG_MANAGED_SETTINGS_JSON='${base64encode(local.managed_settings_json)}' \ /tmp/install.sh EOT } diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh index c00773b5e..62ede95fa 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh +++ b/registry/coder/modules/claude-code/scripts/install.sh @@ -23,6 +23,7 @@ ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-} ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-} ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false} ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-} +ARG_MANAGED_SETTINGS_JSON=$(echo -n "${ARG_MANAGED_SETTINGS_JSON:-}" | base64 -d) export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH" @@ -40,6 +41,7 @@ printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$ARG_MCP_CONFIG_REMOTE_PATH" printf "ARG_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS" printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS" printf "ARG_ENABLE_AIBRIDGE: %s\n" "$ARG_ENABLE_AIBRIDGE" +printf "ARG_MANAGED_SETTINGS_JSON: %s\n" "$ARG_MANAGED_SETTINGS_JSON" echo "--------------------------------" @@ -177,6 +179,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..." @@ -194,13 +222,10 @@ function configure_standalone_mode() { if [ -f "$claude_config" ]; then echo "Updating existing Claude configuration at $claude_config" - jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \ + jq --arg workdir "$ARG_WORKDIR" \ '.autoUpdaterStatus = "disabled" | - .autoModeAccepted = true | - .bypassPermissionsModeAccepted = true | .hasAcknowledgedCostThreshold = true | .hasCompletedOnboarding = true | - .primaryApiKey = $apikey | .projects[$workdir].hasCompletedProjectOnboarding = true | .projects[$workdir].hasTrustDialogAccepted = true' \ "$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config" @@ -209,11 +234,8 @@ function configure_standalone_mode() { cat > "$claude_config" << EOF { "autoUpdaterStatus": "disabled", - "autoModeAccepted": true, - "bypassPermissionsModeAccepted": true, "hasAcknowledgedCostThreshold": true, "hasCompletedOnboarding": true, - "primaryApiKey": "${CLAUDE_API_KEY:-}", "projects": { "$ARG_WORKDIR": { "hasCompletedProjectOnboarding": true, @@ -258,6 +280,7 @@ function accept_auto_mode() { install_claude_code_cli setup_claude_configurations +write_managed_settings report_tasks if [ "$ARG_PERMISSION_MODE" = "auto" ]; then diff --git a/registry/coder/modules/claude-code/scripts/start.sh b/registry/coder/modules/claude-code/scripts/start.sh index 5ccbc8fa1..2382271ee 100644 --- a/registry/coder/modules/claude-code/scripts/start.sh +++ b/registry/coder/modules/claude-code/scripts/start.sh @@ -193,12 +193,19 @@ function start_agentapi() { local session_file session_file=$(get_task_session_file) + # Only force --dangerously-skip-permissions for tasks when no explicit + # permission_mode was configured. An explicit mode (or managed_settings + # policy) should govern the permission posture instead. Same fix as #846. + if [ -z "$ARG_PERMISSION_MODE" ]; then + ARGS+=(--dangerously-skip-permissions) + fi + if task_session_exists && is_valid_session "$session_file"; then echo "Resuming task session: $TASK_SESSION_ID" - ARGS+=(--resume "$TASK_SESSION_ID" --dangerously-skip-permissions) + ARGS+=(--resume "$TASK_SESSION_ID") else echo "Starting new task session: $TASK_SESSION_ID" - ARGS+=(--session-id "$TASK_SESSION_ID" --dangerously-skip-permissions) + ARGS+=(--session-id "$TASK_SESSION_ID") [ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT") fi From c2ce7f617bfabc3040cd0bebe02385bb74291956 Mon Sep 17 00:00:00 2001 From: Morgan Westlee Lunt Date: Fri, 24 Apr 2026 00:01:27 +0000 Subject: [PATCH 2/3] chore: bump README example version 4.9.2 -> 4.9.3 --- registry/coder/modules/claude-code/README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 21180a813..a34d9508d 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_api_key = "xxxx-xxxxx-xxxx" @@ -39,7 +39,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 = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -89,7 +89,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_boundary = true @@ -110,7 +110,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_aibridge = true @@ -139,7 +139,7 @@ data "coder_task" "me" {} module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" ai_prompt = data.coder_task.me.prompt @@ -162,7 +162,7 @@ This example shows additional configuration options for version pinning, custom ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -218,7 +218,7 @@ Run and configure Claude Code as a standalone CLI in your workspace. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" install_claude_code = true @@ -240,7 +240,7 @@ variable "claude_code_oauth_token" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_code_oauth_token = var.claude_code_oauth_token @@ -313,7 +313,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -370,7 +370,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" From 913224b0493faf01ccdec350e9a99a74cfae9b1f Mon Sep 17 00:00:00 2001 From: Morgan Westlee Lunt Date: Fri, 24 Apr 2026 00:11:42 +0000 Subject: [PATCH 3/3] fix tests; drop redundant coder --allowedTools/--disallowedTools calls - The legacy-shim test set disallowed_tools, which triggered a pre-existing 'coder --disallowedTools' call in setup_claude_configurations. coder is not present in the test container so set -e aborted before the policy file was written. Those calls are redundant now that the legacy shim writes allow/deny via managed-settings.d, so remove them. - claude-no-policy-keys-in-claudejson: configure_standalone_mode guards on CLAUDE_API_KEY in the environment, which coder_env provides in production but not in the test container. Pass coderEnvVars to execModuleScript so the file is created. --- registry/coder/modules/claude-code/main.test.ts | 7 +++++-- .../coder/modules/claude-code/scripts/install.sh | 12 ++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 77227cc15..911e8850b 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -252,13 +252,16 @@ describe("claude-code", async () => { }); test("claude-no-policy-keys-in-claudejson", async () => { - const { id } = await setup({ + const { id, coderEnvVars } = await setup({ moduleVariables: { report_tasks: "false", claude_api_key: "sk-test-standalone", }, }); - await execModuleScript(id); + // configure_standalone_mode reads CLAUDE_API_KEY from the environment; + // in production the coder agent exports coder_env values, in tests we + // pass them explicitly. + await execModuleScript(id, coderEnvVars); const cfg = await readFileContainer(id, "/home/coder/.claude.json"); expect(cfg).toContain("hasCompletedOnboarding"); diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh index 62ede95fa..459bd3eb8 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh +++ b/registry/coder/modules/claude-code/scripts/install.sh @@ -169,14 +169,10 @@ function setup_claude_configurations() { ) fi - if [ -n "$ARG_ALLOWED_TOOLS" ]; then - coder --allowedTools "$ARG_ALLOWED_TOOLS" - fi - - if [ -n "$ARG_DISALLOWED_TOOLS" ]; then - coder --disallowedTools "$ARG_DISALLOWED_TOOLS" - fi - + # ARG_ALLOWED_TOOLS / ARG_DISALLOWED_TOOLS are mapped into the + # managed-settings policy file via the legacy_permissions shim in main.tf, + # so the `coder --allowedTools` / `coder --disallowedTools` calls that used + # to live here are no longer needed. } function write_managed_settings() {