## 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>
522 lines
16 KiB
TypeScript
522 lines
16 KiB
TypeScript
import {
|
|
test,
|
|
afterEach,
|
|
describe,
|
|
setDefaultTimeout,
|
|
beforeAll,
|
|
expect,
|
|
} from "bun:test";
|
|
import {
|
|
execContainer,
|
|
readFileContainer,
|
|
removeContainer,
|
|
runContainer,
|
|
runTerraformApply,
|
|
runTerraformInit,
|
|
TerraformState,
|
|
} from "~test";
|
|
import { extractCoderEnvVars, writeExecutable } from "../agentapi/test-util";
|
|
import path from "path";
|
|
|
|
// coder-utils orchestrates this module's scripts and can produce multiple
|
|
// coder_script resources (pre_install, install, post_install). The shared
|
|
// `setup` helper in ../agentapi/test-util.ts assumes a single coder_script
|
|
// via findResourceInstance, so we define a local setup helper that collects
|
|
// every coder_script in run order.
|
|
|
|
interface ModuleScripts {
|
|
pre_install?: string;
|
|
install: string;
|
|
post_install?: string;
|
|
}
|
|
|
|
// Script display_names produced by coder-utils (Claude Code prefix + suffix).
|
|
// Order matters: scripts run sequentially in this order at agent startup.
|
|
const SCRIPT_SUFFIXES = [
|
|
"Pre-Install Script",
|
|
"Install Script",
|
|
"Post-Install Script",
|
|
] as const;
|
|
|
|
const collectScripts = (state: TerraformState): ModuleScripts => {
|
|
const byDisplayName: Record<string, string> = {};
|
|
for (const resource of state.resources) {
|
|
if (resource.type !== "coder_script") continue;
|
|
for (const instance of resource.instances) {
|
|
const attrs = instance.attributes as Record<string, unknown>;
|
|
const displayName = attrs.display_name as string | undefined;
|
|
const script = attrs.script as string | undefined;
|
|
if (displayName && script) {
|
|
byDisplayName[displayName] = script;
|
|
}
|
|
}
|
|
}
|
|
const scripts: Partial<ModuleScripts> = {};
|
|
for (const suffix of SCRIPT_SUFFIXES) {
|
|
const key = `Claude Code: ${suffix}`;
|
|
if (!(key in byDisplayName)) continue;
|
|
switch (suffix) {
|
|
case "Pre-Install Script":
|
|
scripts.pre_install = byDisplayName[key];
|
|
break;
|
|
case "Install Script":
|
|
scripts.install = byDisplayName[key];
|
|
break;
|
|
case "Post-Install Script":
|
|
scripts.post_install = byDisplayName[key];
|
|
break;
|
|
}
|
|
}
|
|
if (!scripts.install) {
|
|
throw new Error("install script not found in terraform state");
|
|
}
|
|
return scripts as ModuleScripts;
|
|
};
|
|
|
|
let cleanupFunctions: (() => Promise<void>)[] = [];
|
|
const registerCleanup = (cleanup: () => Promise<void>) => {
|
|
cleanupFunctions.push(cleanup);
|
|
};
|
|
afterEach(async () => {
|
|
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
|
cleanupFunctions = [];
|
|
for (const cleanup of cleanupFnsCopy) {
|
|
try {
|
|
await cleanup();
|
|
} catch (error) {
|
|
console.error("Error during cleanup:", error);
|
|
}
|
|
}
|
|
});
|
|
|
|
interface SetupProps {
|
|
skipClaudeMock?: boolean;
|
|
moduleVariables?: Record<string, string>;
|
|
}
|
|
|
|
const setup = async (
|
|
props?: SetupProps,
|
|
): Promise<{
|
|
id: string;
|
|
coderEnvVars: Record<string, string>;
|
|
scripts: ModuleScripts;
|
|
}> => {
|
|
const projectDir = "/home/coder/project";
|
|
const moduleDir = path.resolve(import.meta.dir);
|
|
const state = await runTerraformApply(moduleDir, {
|
|
agent_id: "foo",
|
|
workdir: projectDir,
|
|
// Default to skipping the real installer; individual tests opt in.
|
|
install_claude_code: "false",
|
|
...props?.moduleVariables,
|
|
});
|
|
const scripts = collectScripts(state);
|
|
const coderEnvVars = extractCoderEnvVars(state);
|
|
|
|
const id = await runContainer("codercom/enterprise-node:latest");
|
|
registerCleanup(async () => {
|
|
if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") {
|
|
console.log(`Not removing container ${id} in debug mode`);
|
|
return;
|
|
}
|
|
await removeContainer(id);
|
|
});
|
|
|
|
await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]);
|
|
// Mock `coder` CLI so `coder exp sync` calls from coder-utils wrappers
|
|
// succeed without a real control plane.
|
|
await writeExecutable({
|
|
containerId: id,
|
|
filePath: "/usr/bin/coder",
|
|
content: "#!/bin/bash\nexit 0\n",
|
|
});
|
|
if (!props?.skipClaudeMock) {
|
|
await writeExecutable({
|
|
containerId: id,
|
|
filePath: "/usr/bin/claude",
|
|
content: await Bun.file(
|
|
path.join(moduleDir, "testdata", "claude-mock.sh"),
|
|
).text(),
|
|
});
|
|
}
|
|
return { id, coderEnvVars, scripts };
|
|
};
|
|
|
|
// Runs the coder-utils script pipeline (pre_install, install, post_install) in
|
|
// order inside the container. Each script is written to /tmp and executed
|
|
// under bash with the test's env vars exported first.
|
|
const runScripts = async (
|
|
id: string,
|
|
scripts: ModuleScripts,
|
|
env?: Record<string, string>,
|
|
) => {
|
|
const entries = env ? Object.entries(env) : [];
|
|
const envArgs =
|
|
entries.length > 0
|
|
? entries
|
|
.map(
|
|
([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`,
|
|
)
|
|
.join(" && ") + " && "
|
|
: "";
|
|
const ordered: [string, string | undefined][] = [
|
|
["pre_install", scripts.pre_install],
|
|
["install", scripts.install],
|
|
["post_install", scripts.post_install],
|
|
];
|
|
for (const [name, script] of ordered) {
|
|
if (!script) continue;
|
|
const target = `/tmp/coder-utils-${name}.sh`;
|
|
await writeExecutable({
|
|
containerId: id,
|
|
filePath: target,
|
|
content: script,
|
|
});
|
|
const resp = await execContainer(id, ["bash", "-c", `${envArgs}${target}`]);
|
|
if (resp.exitCode !== 0) {
|
|
console.log(`script ${name} failed:`);
|
|
console.log(resp.stdout);
|
|
console.log(resp.stderr);
|
|
throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
setDefaultTimeout(60 * 1000);
|
|
|
|
describe("claude-code", async () => {
|
|
beforeAll(async () => {
|
|
await runTerraformInit(import.meta.dir);
|
|
});
|
|
|
|
test("happy-path", async () => {
|
|
const { id, scripts } = await setup();
|
|
await runScripts(id, scripts);
|
|
const installLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
|
);
|
|
expect(installLog).toContain("Skipping Claude Code installation");
|
|
});
|
|
|
|
test("install-claude-code-version", async () => {
|
|
const version = "1.0.40";
|
|
const { id, coderEnvVars, scripts } = await setup({
|
|
skipClaudeMock: true,
|
|
moduleVariables: {
|
|
install_claude_code: "true",
|
|
claude_code_version: version,
|
|
},
|
|
});
|
|
await runScripts(id, scripts, coderEnvVars);
|
|
const installLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
|
);
|
|
expect(installLog).toContain(version);
|
|
});
|
|
|
|
test("anthropic-api-key", async () => {
|
|
const apiKey = "test-api-key-123";
|
|
const { coderEnvVars } = await setup({
|
|
moduleVariables: {
|
|
anthropic_api_key: apiKey,
|
|
},
|
|
});
|
|
expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBe(apiKey);
|
|
});
|
|
|
|
test("claude-code-oauth-token", async () => {
|
|
const token = "test-oauth-token-456";
|
|
const { coderEnvVars } = await setup({
|
|
moduleVariables: {
|
|
claude_code_oauth_token: token,
|
|
},
|
|
});
|
|
expect(coderEnvVars["CLAUDE_CODE_OAUTH_TOKEN"]).toBe(token);
|
|
});
|
|
|
|
test("claude-mcp-config", async () => {
|
|
const mcpConfig = JSON.stringify({
|
|
mcpServers: {
|
|
test: {
|
|
command: "test-cmd",
|
|
type: "stdio",
|
|
},
|
|
},
|
|
});
|
|
const { id, coderEnvVars, scripts } = await setup({
|
|
skipClaudeMock: true,
|
|
moduleVariables: {
|
|
install_claude_code: "true",
|
|
mcp: mcpConfig,
|
|
},
|
|
});
|
|
await runScripts(id, scripts, coderEnvVars);
|
|
const claudeConfig = await readFileContainer(
|
|
id,
|
|
"/home/coder/.claude.json",
|
|
);
|
|
expect(claudeConfig).toContain("test-cmd");
|
|
});
|
|
|
|
test("claude-model", async () => {
|
|
const model = "opus";
|
|
const { coderEnvVars } = await setup({
|
|
moduleVariables: {
|
|
model,
|
|
},
|
|
});
|
|
expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model);
|
|
});
|
|
|
|
test("pre-post-install-scripts", async () => {
|
|
const { id, scripts } = await setup({
|
|
moduleVariables: {
|
|
pre_install_script: "#!/bin/bash\necho 'claude-pre-install-script'",
|
|
post_install_script: "#!/bin/bash\necho 'claude-post-install-script'",
|
|
},
|
|
});
|
|
await runScripts(id, scripts);
|
|
|
|
const preInstallLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder/claude-code/logs/pre_install.log",
|
|
);
|
|
expect(preInstallLog).toContain("claude-pre-install-script");
|
|
|
|
const postInstallLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder/claude-code/logs/post_install.log",
|
|
);
|
|
expect(postInstallLog).toContain("claude-post-install-script");
|
|
});
|
|
|
|
test("workdir-variable", async () => {
|
|
const workdir = "/home/coder/claude-test-folder";
|
|
const { id, scripts } = await setup({
|
|
moduleVariables: {
|
|
workdir,
|
|
},
|
|
});
|
|
await runScripts(id, scripts);
|
|
// install.sh.tftpl echoes ARG_WORKDIR and creates the directory if missing.
|
|
const installLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
|
);
|
|
expect(installLog).toContain(workdir);
|
|
});
|
|
|
|
test("mcp-config-remote-path", async () => {
|
|
const failingUrl = "http://localhost:19999/mcp.json";
|
|
const successUrl =
|
|
"https://raw.githubusercontent.com/coder/coder/main/.mcp.json";
|
|
|
|
const { id, coderEnvVars, scripts } = await setup({
|
|
skipClaudeMock: true,
|
|
moduleVariables: {
|
|
install_claude_code: "true",
|
|
mcp_config_remote_path: JSON.stringify([failingUrl, successUrl]),
|
|
},
|
|
});
|
|
await runScripts(id, scripts, coderEnvVars);
|
|
|
|
const installLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
|
);
|
|
|
|
// Verify both URLs are attempted.
|
|
expect(installLog).toContain(failingUrl);
|
|
expect(installLog).toContain(successUrl);
|
|
|
|
// First URL should fail gracefully.
|
|
expect(installLog).toContain(
|
|
`Warning: Failed to fetch MCP configuration from '${failingUrl}'`,
|
|
);
|
|
|
|
// Second URL should succeed.
|
|
expect(installLog).not.toContain(
|
|
`Warning: Failed to fetch MCP configuration from '${successUrl}'`,
|
|
);
|
|
|
|
// Should contain the MCP server add command from the successful fetch.
|
|
expect(installLog).toContain(
|
|
"Added stdio MCP server go-language-server to user config",
|
|
);
|
|
expect(installLog).toContain(
|
|
"Added stdio MCP server typescript-language-server to user config",
|
|
);
|
|
|
|
// Verify the MCP config was added to .claude.json.
|
|
const claudeConfig = await readFileContainer(
|
|
id,
|
|
"/home/coder/.claude.json",
|
|
);
|
|
expect(claudeConfig).toContain("typescript-language-server");
|
|
expect(claudeConfig).toContain("go-language-server");
|
|
});
|
|
|
|
test("standalone-mode-with-api-key", async () => {
|
|
const apiKey = "test-api-key-standalone";
|
|
const workdir = "/home/coder/project";
|
|
const { id, coderEnvVars, scripts } = await setup({
|
|
moduleVariables: {
|
|
anthropic_api_key: apiKey,
|
|
},
|
|
});
|
|
await runScripts(id, scripts, coderEnvVars);
|
|
|
|
const installLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
|
);
|
|
expect(installLog).toContain("Configuring Claude Code for standalone mode");
|
|
expect(installLog).toContain("Standalone mode configured successfully");
|
|
|
|
const claudeConfig = await readFileContainer(
|
|
id,
|
|
"/home/coder/.claude.json",
|
|
);
|
|
const parsed = JSON.parse(claudeConfig);
|
|
expect(parsed.autoUpdaterStatus).toBe("disabled");
|
|
expect(parsed.hasCompletedOnboarding).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 () => {
|
|
const token = "test-oauth-token-standalone";
|
|
const { id, coderEnvVars, scripts } = await setup({
|
|
moduleVariables: {
|
|
claude_code_oauth_token: token,
|
|
},
|
|
});
|
|
await runScripts(id, scripts, coderEnvVars);
|
|
|
|
const installLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
|
);
|
|
expect(installLog).toContain("Standalone mode configured successfully");
|
|
expect(installLog).not.toContain("skipping onboarding bypass");
|
|
|
|
// Onboarding bypass flags must be present. Authentication happens via
|
|
// the ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN env vars, not via
|
|
// .claude.json.
|
|
const claudeConfig = await readFileContainer(
|
|
id,
|
|
"/home/coder/.claude.json",
|
|
);
|
|
const parsed = JSON.parse(claudeConfig);
|
|
expect(parsed.hasCompletedOnboarding).toBe(true);
|
|
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
|
|
});
|
|
|
|
test("standalone-mode-no-auth", async () => {
|
|
const { id, coderEnvVars, scripts } = await setup();
|
|
await runScripts(id, scripts, coderEnvVars);
|
|
|
|
const installLog = await readFileContainer(
|
|
id,
|
|
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
|
|
);
|
|
expect(installLog).toContain("No authentication configured");
|
|
expect(installLog).toContain("skipping onboarding bypass");
|
|
|
|
// .claude.json should not exist when no auth is configured.
|
|
const resp = await execContainer(id, [
|
|
"bash",
|
|
"-c",
|
|
"test -e /home/coder/.claude.json && echo EXISTS || echo ABSENT",
|
|
]);
|
|
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: {
|
|
telemetry: JSON.stringify({
|
|
enabled: true,
|
|
otlp_endpoint: "http://otel-collector:4317",
|
|
otlp_protocol: "grpc",
|
|
otlp_headers: { authorization: "Bearer test-token" },
|
|
resource_attributes: { "service.name": "claude-code" },
|
|
}),
|
|
},
|
|
});
|
|
expect(coderEnvVars["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBe("1");
|
|
expect(coderEnvVars["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe(
|
|
"http://otel-collector:4317",
|
|
);
|
|
expect(coderEnvVars["OTEL_EXPORTER_OTLP_PROTOCOL"]).toBe("grpc");
|
|
expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBe(
|
|
"authorization=Bearer test-token",
|
|
);
|
|
const attrs = coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"];
|
|
expect(attrs).toContain("coder.workspace_id=");
|
|
expect(attrs).toContain("coder.workspace_name=");
|
|
expect(attrs).toContain("coder.workspace_owner=");
|
|
expect(attrs).toContain("coder.template_name=");
|
|
expect(attrs).toContain("service.name=claude-code");
|
|
});
|
|
|
|
test("telemetry-disabled-by-default", async () => {
|
|
const { coderEnvVars } = await setup();
|
|
expect(coderEnvVars["CLAUDE_CODE_ENABLE_TELEMETRY"]).toBeUndefined();
|
|
expect(coderEnvVars["OTEL_EXPORTER_OTLP_ENDPOINT"]).toBeUndefined();
|
|
expect(coderEnvVars["OTEL_EXPORTER_OTLP_PROTOCOL"]).toBeUndefined();
|
|
expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBeUndefined();
|
|
expect(coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"]).toBeUndefined();
|
|
});
|
|
});
|