Morgan Lunt 4ca251f448
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>
2026-05-15 08:27:42 -05:00

218 lines
7.2 KiB
Plaintext

#!/bin/bash
set -euo pipefail
BOLD='\033[0;1m'
command_exists() {
command -v "$1" > /dev/null 2>&1
}
ARG_CLAUDE_CODE_VERSION='${ARG_CLAUDE_CODE_VERSION}'
ARG_WORKDIR='${ARG_WORKDIR}'
ARG_INSTALL_CLAUDE_CODE='${ARG_INSTALL_CLAUDE_CODE}'
ARG_CLAUDE_BINARY_PATH='${ARG_CLAUDE_BINARY_PATH}'
ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}"
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"
echo "--------------------------------"
printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$${ARG_CLAUDE_CODE_VERSION}"
printf "ARG_WORKDIR: %s\n" "$${ARG_WORKDIR}"
printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$${ARG_INSTALL_CLAUDE_CODE}"
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 "--------------------------------"
function add_mcp_servers() {
local mcp_json="$1"
local source_desc="$2"
while IFS= read -r server_name && IFS= read -r server_json; do
echo "------------------------"
echo "Executing: claude mcp add-json --scope user \"$${server_name}\" '$${server_json}' ($${source_desc})"
claude mcp add-json --scope user "$${server_name}" "$${server_json}" || echo "Warning: Failed to add MCP server '$${server_name}', continuing..."
echo "------------------------"
echo ""
done < <(echo "$${mcp_json}" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)')
}
function add_path_to_shell_profiles() {
local path_dir="$1"
for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do
if [ -f "$${profile}" ]; then
if ! grep -q "$${path_dir}" "$${profile}" 2> /dev/null; then
echo "export PATH=\"\$PATH:$${path_dir}\"" >> "$${profile}"
echo "Added $${path_dir} to $${profile}"
fi
fi
done
local fish_config="$HOME/.config/fish/config.fish"
if [ -f "$${fish_config}" ]; then
if ! grep -q "$${path_dir}" "$${fish_config}" 2> /dev/null; then
echo "fish_add_path $${path_dir}" >> "$${fish_config}"
echo "Added $${path_dir} to $${fish_config}"
fi
fi
}
function ensure_claude_in_path() {
local CLAUDE_BIN=""
if command -v claude > /dev/null 2>&1; then
CLAUDE_BIN=$(command -v claude)
elif [ -x "$${ARG_CLAUDE_BINARY_PATH}/claude" ]; then
CLAUDE_BIN="$${ARG_CLAUDE_BINARY_PATH}/claude"
elif [ -x "$HOME/.local/bin/claude" ]; then
CLAUDE_BIN="$HOME/.local/bin/claude"
fi
if [ -z "$${CLAUDE_BIN}" ] || [ ! -x "$${CLAUDE_BIN}" ]; then
echo "Warning: Could not find claude binary"
return
fi
local CLAUDE_DIR
CLAUDE_DIR=$(dirname "$${CLAUDE_BIN}")
if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/claude" ]; then
ln -s "$${CLAUDE_BIN}" "$${CODER_SCRIPT_BIN_DIR}/claude"
echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/claude -> $${CLAUDE_BIN}"
fi
add_path_to_shell_profiles "$${CLAUDE_DIR}"
}
function install_claude_code_cli() {
if [ "$${ARG_INSTALL_CLAUDE_CODE}" != "true" ]; then
echo "Skipping Claude Code installation as per configuration."
ensure_claude_in_path
return
fi
echo "Installing Claude Code via official installer"
set +e
curl -fsSL claude.ai/install.sh | bash -s -- "$${ARG_CLAUDE_CODE_VERSION}" 2>&1
CURL_EXIT=$${PIPESTATUS[0]}
set -e
if [ $${CURL_EXIT} -ne 0 ]; then
echo "Claude Code installer failed with exit code $${CURL_EXIT}"
fi
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
ensure_claude_in_path
}
function setup_claude_configurations() {
if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then
echo "Warning: The specified folder '$${ARG_WORKDIR}' does not exist."
echo "Creating the folder..."
mkdir -p "$${ARG_WORKDIR}"
echo "Folder created successfully."
fi
module_path="$HOME/.coder-modules/coder/claude-code"
mkdir -p "$${module_path}"
if [ "$${ARG_MCP}" != "" ]; then
add_mcp_servers "$${ARG_MCP}" "from module input"
fi
if [ -n "$${ARG_MCP_CONFIG_REMOTE_PATH}" ] && [ "$${ARG_MCP_CONFIG_REMOTE_PATH}" != "[]" ]; then
for url in $(echo "$${ARG_MCP_CONFIG_REMOTE_PATH}" | jq -r '.[]'); do
echo "Fetching MCP configuration from: $${url}"
mcp_json=$(curl -fsSL "$${url}") || {
echo "Warning: Failed to fetch MCP configuration from '$${url}', continuing..."
continue
}
if ! echo "$${mcp_json}" | jq -e '.mcpServers' > /dev/null 2>&1; then
echo "Warning: Invalid MCP configuration from '$${url}' (missing mcpServers), continuing..."
continue
fi
add_mcp_servers "$${mcp_json}" "from $${url}"
done
fi
}
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..."
if [ -z "$${ANTHROPIC_API_KEY:-}" ] && [ -z "$${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ "$${ARG_ENABLE_AI_GATEWAY}" = "false" ]; then
echo "Note: No authentication configured (anthropic_api_key, claude_code_oauth_token, enable_ai_gateway), skipping onboarding bypass"
return
fi
local claude_config="$HOME/.claude.json"
if [ -f "$${claude_config}" ]; then
echo "Updating existing Claude configuration at $${claude_config}"
jq '.autoUpdaterStatus = "disabled" |
.hasAcknowledgedCostThreshold = true |
.hasCompletedOnboarding = true' \
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
else
echo "Creating new Claude configuration at $${claude_config}"
cat > "$${claude_config}" << EOF
{
"autoUpdaterStatus": "disabled",
"hasAcknowledgedCostThreshold": true,
"hasCompletedOnboarding": true
}
EOF
fi
if [ -n "$${ARG_WORKDIR}" ]; then
echo "Pre-accepting trust dialog for $${ARG_WORKDIR}"
jq --arg workdir "$${ARG_WORKDIR}" \
'.projects[$workdir].hasCompletedProjectOnboarding = true |
.projects[$workdir].hasTrustDialogAccepted = true' \
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
fi
echo "Standalone mode configured successfully"
}
install_claude_code_cli
setup_claude_configurations
write_managed_settings
configure_standalone_mode