Skip to content

Commit ef8cf71

Browse files
committed
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.
1 parent 39f332f commit ef8cf71

5 files changed

Lines changed: 161 additions & 13 deletions

File tree

registry/coder/modules/claude-code/README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ module "claude-code" {
2121
```
2222

2323
> [!WARNING]
24-
> **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.
24+
> **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.
2525
2626
> [!NOTE]
2727
> 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" {
3232
- You can get the API key from the [Anthropic Console](https://console.anthropic.com/dashboard).
3333
- You can get the Session Token using the `claude setup-token` command. This is a long-lived authentication token (requires Claude subscription)
3434

35+
### Enterprise policy via managed settings
36+
37+
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).
38+
39+
```tf
40+
module "claude-code" {
41+
source = "registry.coder.com/coder/claude-code/coder"
42+
version = "4.9.2"
43+
agent_id = coder_agent.main.id
44+
workdir = "/home/coder/project"
45+
46+
managed_settings = {
47+
permissions = {
48+
defaultMode = "acceptEdits"
49+
disableBypassPermissionsMode = "disable"
50+
deny = ["Bash(curl:*)", "Bash(wget:*)", "WebFetch"]
51+
}
52+
env = {
53+
DISABLE_TELEMETRY = "0"
54+
}
55+
}
56+
}
57+
```
58+
59+
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).
60+
61+
> [!NOTE]
62+
> 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.
63+
3564
### Session Resumption Behavior
3665

3766
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`

registry/coder/modules/claude-code/main.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,72 @@ describe("claude-code", async () => {
198198
"cat /home/coder/.claude-module/agentapi-start.log",
199199
]);
200200
expect(startLog.stdout).toContain(`--permission-mode ${mode}`);
201+
// With an explicit permission_mode, tasks must not also force
202+
// --dangerously-skip-permissions on top of it.
203+
expect(startLog.stdout).not.toContain("--dangerously-skip-permissions");
204+
});
205+
206+
test("claude-managed-settings-written", async () => {
207+
const { id } = await setup({
208+
moduleVariables: {
209+
managed_settings: JSON.stringify({
210+
permissions: {
211+
defaultMode: "acceptEdits",
212+
deny: ["Bash(rm -rf*)"],
213+
},
214+
}),
215+
},
216+
});
217+
await execModuleScript(id);
218+
219+
const policy = await execContainer(id, [
220+
"bash",
221+
"-c",
222+
"cat /etc/claude-code/managed-settings.d/10-coder.json",
223+
]);
224+
expect(policy.exitCode).toBe(0);
225+
expect(policy.stdout).toContain('"defaultMode":"acceptEdits"');
226+
expect(policy.stdout).toContain('"deny":["Bash(rm -rf*)"]');
227+
228+
const installLog = await readFileContainer(
229+
id,
230+
"/home/coder/.claude-module/install.log",
231+
);
232+
expect(installLog).toContain("Wrote Claude Code managed settings");
233+
});
234+
235+
test("claude-managed-settings-legacy-shim", async () => {
236+
const { id } = await setup({
237+
moduleVariables: {
238+
permission_mode: "plan",
239+
disallowed_tools: "Bash(curl:*),WebFetch",
240+
},
241+
});
242+
await execModuleScript(id);
243+
244+
const policy = await execContainer(id, [
245+
"bash",
246+
"-c",
247+
"cat /etc/claude-code/managed-settings.d/10-coder.json",
248+
]);
249+
expect(policy.exitCode).toBe(0);
250+
expect(policy.stdout).toContain('"defaultMode":"plan"');
251+
expect(policy.stdout).toContain('"deny":["Bash(curl:*)","WebFetch"]');
252+
});
253+
254+
test("claude-no-policy-keys-in-claudejson", async () => {
255+
const { id } = await setup({
256+
moduleVariables: {
257+
report_tasks: "false",
258+
claude_api_key: "sk-test-standalone",
259+
},
260+
});
261+
await execModuleScript(id);
262+
263+
const cfg = await readFileContainer(id, "/home/coder/.claude.json");
264+
expect(cfg).toContain("hasCompletedOnboarding");
265+
expect(cfg).not.toContain("bypassPermissionsModeAccepted");
266+
expect(cfg).not.toContain("primaryApiKey");
201267
});
202268

203269
test("claude-model", async () => {

registry/coder/modules/claude-code/main.tf

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ variable "dangerously_skip_permissions" {
158158

159159
variable "permission_mode" {
160160
type = string
161-
description = "Permission mode for the cli, check https://docs.anthropic.com/en/docs/claude-code/iam#permission-modes"
161+
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"
162162
default = ""
163163
validation {
164164
condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode)
@@ -180,15 +180,20 @@ variable "mcp_config_remote_path" {
180180

181181
variable "allowed_tools" {
182182
type = string
183-
description = "A list of tools that should be allowed without prompting the user for permission, in addition to settings.json files."
183+
description = "Deprecated: use managed_settings.permissions.allow instead. A comma-separated list of tools that should be allowed without prompting the user for permission."
184184
default = ""
185185
}
186186

187187
variable "disallowed_tools" {
188188
type = string
189-
description = "A list of tools that should be disallowed without prompting the user for permission, in addition to settings.json files."
189+
description = "Deprecated: use managed_settings.permissions.deny instead. A comma-separated list of tools that should be disallowed without prompting the user for permission."
190190
default = ""
191+
}
191192

193+
variable "managed_settings" {
194+
type = any
195+
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."
196+
default = null
192197
}
193198

194199
variable "claude_code_oauth_token" {
@@ -334,6 +339,23 @@ locals {
334339
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
335340
claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key
336341

342+
# Deprecation shim: map legacy permission vars into managed-settings shape
343+
# when managed_settings is not provided. Removed once the legacy vars are dropped.
344+
legacy_permissions = merge(
345+
var.permission_mode != "" ? { defaultMode = var.permission_mode } : {},
346+
var.allowed_tools != "" ? { allow = [for t in split(",", var.allowed_tools) : trimspace(t)] } : {},
347+
var.disallowed_tools != "" ? { deny = [for t in split(",", var.disallowed_tools) : trimspace(t)] } : {},
348+
)
349+
managed_settings_json = (
350+
var.managed_settings != null
351+
? jsonencode(var.managed_settings)
352+
: (
353+
length(local.legacy_permissions) > 0
354+
? jsonencode({ permissions = local.legacy_permissions })
355+
: ""
356+
)
357+
)
358+
337359
# Required prompts for the module to properly report task status to Coder
338360
report_tasks_system_prompt = <<-EOT
339361
-- Tool Selection --
@@ -431,6 +453,7 @@ module "agentapi" {
431453
ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \
432454
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
433455
ARG_PERMISSION_MODE='${var.permission_mode}' \
456+
ARG_MANAGED_SETTINGS_JSON='${base64encode(local.managed_settings_json)}' \
434457
/tmp/install.sh
435458
EOT
436459
}

registry/coder/modules/claude-code/scripts/install.sh

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-}
2323
ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-}
2424
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
2525
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
26+
ARG_MANAGED_SETTINGS_JSON=$(echo -n "${ARG_MANAGED_SETTINGS_JSON:-}" | base64 -d)
2627

2728
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
2829

@@ -40,6 +41,7 @@ printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$ARG_MCP_CONFIG_REMOTE_PATH"
4041
printf "ARG_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS"
4142
printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS"
4243
printf "ARG_ENABLE_AIBRIDGE: %s\n" "$ARG_ENABLE_AIBRIDGE"
44+
printf "ARG_MANAGED_SETTINGS_JSON: %s\n" "$ARG_MANAGED_SETTINGS_JSON"
4345

4446
echo "--------------------------------"
4547

@@ -177,6 +179,32 @@ function setup_claude_configurations() {
177179

178180
}
179181

182+
function write_managed_settings() {
183+
if [ -z "$ARG_MANAGED_SETTINGS_JSON" ]; then
184+
return
185+
fi
186+
187+
local dropin_dir="/etc/claude-code/managed-settings.d"
188+
local target="$dropin_dir/10-coder.json"
189+
190+
if ! echo "$ARG_MANAGED_SETTINGS_JSON" | jq empty 2> /dev/null; then
191+
echo "Warning: managed_settings is not valid JSON, skipping policy write"
192+
return
193+
fi
194+
195+
if command_exists sudo; then
196+
sudo mkdir -p "$dropin_dir"
197+
echo "$ARG_MANAGED_SETTINGS_JSON" | sudo tee "$target" > /dev/null
198+
sudo chmod 0644 "$target"
199+
else
200+
mkdir -p "$dropin_dir"
201+
echo "$ARG_MANAGED_SETTINGS_JSON" > "$target"
202+
chmod 0644 "$target"
203+
fi
204+
205+
echo "Wrote Claude Code managed settings to $target"
206+
}
207+
180208
function configure_standalone_mode() {
181209
echo "Configuring Claude Code for standalone mode..."
182210

@@ -194,13 +222,10 @@ function configure_standalone_mode() {
194222
if [ -f "$claude_config" ]; then
195223
echo "Updating existing Claude configuration at $claude_config"
196224

197-
jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \
225+
jq --arg workdir "$ARG_WORKDIR" \
198226
'.autoUpdaterStatus = "disabled" |
199-
.autoModeAccepted = true |
200-
.bypassPermissionsModeAccepted = true |
201227
.hasAcknowledgedCostThreshold = true |
202228
.hasCompletedOnboarding = true |
203-
.primaryApiKey = $apikey |
204229
.projects[$workdir].hasCompletedProjectOnboarding = true |
205230
.projects[$workdir].hasTrustDialogAccepted = true' \
206231
"$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config"
@@ -209,11 +234,8 @@ function configure_standalone_mode() {
209234
cat > "$claude_config" << EOF
210235
{
211236
"autoUpdaterStatus": "disabled",
212-
"autoModeAccepted": true,
213-
"bypassPermissionsModeAccepted": true,
214237
"hasAcknowledgedCostThreshold": true,
215238
"hasCompletedOnboarding": true,
216-
"primaryApiKey": "${CLAUDE_API_KEY:-}",
217239
"projects": {
218240
"$ARG_WORKDIR": {
219241
"hasCompletedProjectOnboarding": true,
@@ -258,6 +280,7 @@ function accept_auto_mode() {
258280

259281
install_claude_code_cli
260282
setup_claude_configurations
283+
write_managed_settings
261284
report_tasks
262285

263286
if [ "$ARG_PERMISSION_MODE" = "auto" ]; then

registry/coder/modules/claude-code/scripts/start.sh

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,12 +193,19 @@ function start_agentapi() {
193193
local session_file
194194
session_file=$(get_task_session_file)
195195

196+
# Only force --dangerously-skip-permissions for tasks when no explicit
197+
# permission_mode was configured. An explicit mode (or managed_settings
198+
# policy) should govern the permission posture instead. Same fix as #846.
199+
if [ -z "$ARG_PERMISSION_MODE" ]; then
200+
ARGS+=(--dangerously-skip-permissions)
201+
fi
202+
196203
if task_session_exists && is_valid_session "$session_file"; then
197204
echo "Resuming task session: $TASK_SESSION_ID"
198-
ARGS+=(--resume "$TASK_SESSION_ID" --dangerously-skip-permissions)
205+
ARGS+=(--resume "$TASK_SESSION_ID")
199206
else
200207
echo "Starting new task session: $TASK_SESSION_ID"
201-
ARGS+=(--session-id "$TASK_SESSION_ID" --dangerously-skip-permissions)
208+
ARGS+=(--session-id "$TASK_SESSION_ID")
202209
[ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT")
203210
fi
204211

0 commit comments

Comments
 (0)