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 <chris@dualriver.com>
Co-authored-by: DevCats <christofer@coder.com>
This commit is contained in:
parent
99510a1f75
commit
4ca251f448
@ -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"
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user