Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions registry/coder/modules/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ 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"
}
```

> [!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.
Expand All @@ -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`
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
69 changes: 69 additions & 0 deletions registry/coder/modules/claude-code/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
29 changes: 26 additions & 3 deletions registry/coder/modules/claude-code/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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" {
Expand Down Expand Up @@ -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 --
Expand Down Expand Up @@ -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
}
Expand Down
41 changes: 30 additions & 11 deletions registry/coder/modules/claude-code/scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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 "--------------------------------"

Expand Down Expand Up @@ -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() {
Expand All @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions registry/coder/modules/claude-code/scripts/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down