diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 48b291bb0..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" @@ -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.3" + 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` @@ -60,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 @@ -81,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 @@ -110,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 @@ -133,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" @@ -189,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 @@ -211,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 @@ -284,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" @@ -341,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" diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index b01e88327..911e8850b 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -198,6 +198,75 @@ 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, coderEnvVars } = await setup({ + moduleVariables: { + report_tasks: "false", + claude_api_key: "sk-test-standalone", + }, + }); + // 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"); + 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..459bd3eb8 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 "--------------------------------" @@ -167,14 +169,36 @@ function setup_claude_configurations() { ) fi - if [ -n "$ARG_ALLOWED_TOOLS" ]; then - coder --allowedTools "$ARG_ALLOWED_TOOLS" + # 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() { + 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 [ -n "$ARG_DISALLOWED_TOOLS" ]; then - coder --disallowedTools "$ARG_DISALLOWED_TOOLS" + 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() { @@ -194,13 +218,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 +230,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 +276,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