## 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>
218 lines
7.2 KiB
Plaintext
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
|