Project and local settings additively merge their permissions with whatever
allowed_tools a workflow specifies. A workflow author writing a restrictive
allowlist reasonably expects it to be the complete allow-set, but
.claude/settings.json can silently expand it.
Changes:
- Add setting_sources as a first-class input to both actions (previously
only reachable via --setting-sources in claude_args)
- base-action now defaults to settingSources: ['user'] — workflows that want
project/local settings must opt in explicitly
- Main action defaults to 'user,project,local' since .claude/ is restored
from the PR base branch before execution, so project settings are
maintainer-trusted in that context
- Precedence: setting_sources input > --setting-sources in claude_args > default
Breaking change for base-action: workflows relying on .claude/settings.json
being loaded automatically need to add setting_sources: 'user,project,local'.
🏠 Remote-Dev: homespace
481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
import { describe, test, expect } from "bun:test";
|
|
import { parseSdkOptions } from "../src/parse-sdk-options";
|
|
import type { ClaudeOptions } from "../src/run-claude";
|
|
|
|
describe("parseSdkOptions", () => {
|
|
describe("allowedTools merging", () => {
|
|
test("should extract allowedTools from claudeArgs", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: '--allowedTools "Edit,Read,Write"',
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
|
|
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
|
});
|
|
|
|
test("should extract allowedTools from claudeArgs with MCP tools", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs:
|
|
'--allowedTools "Edit,Read,mcp__github_comment__update_claude_comment"',
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.allowedTools).toEqual([
|
|
"Edit",
|
|
"Read",
|
|
"mcp__github_comment__update_claude_comment",
|
|
]);
|
|
});
|
|
|
|
test("should accumulate multiple --allowedTools flags from claudeArgs", () => {
|
|
// This simulates tag mode adding its tools, then user adding their own
|
|
const options: ClaudeOptions = {
|
|
claudeArgs:
|
|
'--allowedTools "Edit,Read,mcp__github_comment__update_claude_comment" --model "claude-3" --allowedTools "Bash(npm install),mcp__github__get_issue"',
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.allowedTools).toEqual([
|
|
"Edit",
|
|
"Read",
|
|
"mcp__github_comment__update_claude_comment",
|
|
"Bash(npm install)",
|
|
"mcp__github__get_issue",
|
|
]);
|
|
});
|
|
|
|
test("should merge allowedTools from both claudeArgs and direct options", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: '--allowedTools "Edit,Read"',
|
|
allowedTools: "Write,Glob",
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.allowedTools).toEqual([
|
|
"Edit",
|
|
"Read",
|
|
"Write",
|
|
"Glob",
|
|
]);
|
|
});
|
|
|
|
test("should deduplicate allowedTools when merging", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: '--allowedTools "Edit,Read"',
|
|
allowedTools: "Edit,Write",
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
|
|
});
|
|
|
|
test("should use only direct options when claudeArgs has no allowedTools", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: '--model "claude-3-5-sonnet"',
|
|
allowedTools: "Edit,Read",
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read"]);
|
|
});
|
|
|
|
test("should return undefined allowedTools when neither source has it", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: '--model "claude-3-5-sonnet"',
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.allowedTools).toBeUndefined();
|
|
});
|
|
|
|
test("should remove allowedTools from extraArgs after extraction", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: '--allowedTools "Edit,Read" --model "claude-3-5-sonnet"',
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
|
});
|
|
|
|
test("should handle hyphenated --allowed-tools flag", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: '--allowed-tools "Edit,Read,Write"',
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
|
|
expect(result.sdkOptions.extraArgs?.["allowed-tools"]).toBeUndefined();
|
|
});
|
|
|
|
test("should accumulate multiple --allowed-tools flags (hyphenated)", () => {
|
|
// This is the exact scenario from issue #746
|
|
const options: ClaudeOptions = {
|
|
claudeArgs:
|
|
'--allowed-tools "Bash(git log:*)" "Bash(git diff:*)" "Bash(git fetch:*)" "Bash(gh pr:*)"',
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.allowedTools).toEqual([
|
|
"Bash(git log:*)",
|
|
"Bash(git diff:*)",
|
|
"Bash(git fetch:*)",
|
|
"Bash(gh pr:*)",
|
|
]);
|
|
});
|
|
|
|
test("should handle mixed camelCase and hyphenated allowedTools flags", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: '--allowedTools "Edit,Read" --allowed-tools "Write,Glob"',
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
// Both should be merged - note: order depends on which key is found first
|
|
expect(result.sdkOptions.allowedTools).toContain("Edit");
|
|
expect(result.sdkOptions.allowedTools).toContain("Read");
|
|
expect(result.sdkOptions.allowedTools).toContain("Write");
|
|
expect(result.sdkOptions.allowedTools).toContain("Glob");
|
|
});
|
|
});
|
|
|
|
describe("disallowedTools merging", () => {
|
|
test("should extract disallowedTools from claudeArgs", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: '--disallowedTools "Bash,Write"',
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.disallowedTools).toEqual(["Bash", "Write"]);
|
|
expect(result.sdkOptions.extraArgs?.["disallowedTools"]).toBeUndefined();
|
|
});
|
|
|
|
test("should merge disallowedTools from both sources", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: '--disallowedTools "Bash"',
|
|
disallowedTools: "Write",
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.disallowedTools).toEqual(["Bash", "Write"]);
|
|
});
|
|
});
|
|
|
|
describe("mcp-config merging", () => {
|
|
test("should pass through single mcp-config in extraArgs", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{"command":"cmd1"}}}'`,
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
|
|
'{"mcpServers":{"server1":{"command":"cmd1"}}}',
|
|
);
|
|
});
|
|
|
|
test("should merge multiple mcp-config flags with inline JSON", () => {
|
|
// Simulates action prepending its config, then user providing their own
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node","args":["server.js"]}}}' --mcp-config '{"mcpServers":{"user_server":{"command":"custom","args":["run"]}}}'`,
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
const mcpConfig = JSON.parse(
|
|
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
|
);
|
|
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
|
|
expect(mcpConfig.mcpServers).toHaveProperty("user_server");
|
|
expect(mcpConfig.mcpServers.github_comment.command).toBe("node");
|
|
expect(mcpConfig.mcpServers.user_server.command).toBe("custom");
|
|
});
|
|
|
|
test("should merge three mcp-config flags", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{"command":"cmd1"}}}' --mcp-config '{"mcpServers":{"server2":{"command":"cmd2"}}}' --mcp-config '{"mcpServers":{"server3":{"command":"cmd3"}}}'`,
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
const mcpConfig = JSON.parse(
|
|
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
|
);
|
|
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
|
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
|
expect(mcpConfig.mcpServers).toHaveProperty("server3");
|
|
});
|
|
|
|
test("should handle mcp-config file path when no inline JSON exists", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: `--mcp-config /tmp/user-mcp-config.json`,
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
|
|
"/tmp/user-mcp-config.json",
|
|
);
|
|
});
|
|
|
|
test("should merge inline JSON configs when file path is also present", () => {
|
|
// When action provides inline JSON and user provides a file path,
|
|
// the inline JSON configs should be merged (file paths cannot be merged at parse time)
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node"}}}' --mcp-config '{"mcpServers":{"github_ci":{"command":"node"}}}' --mcp-config /tmp/user-config.json`,
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
// The inline JSON configs should be merged
|
|
const mcpConfig = JSON.parse(
|
|
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
|
);
|
|
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
|
|
expect(mcpConfig.mcpServers).toHaveProperty("github_ci");
|
|
});
|
|
|
|
test("should handle mcp-config with other flags", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{}}}' --model claude-3-5-sonnet --mcp-config '{"mcpServers":{"server2":{}}}'`,
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
const mcpConfig = JSON.parse(
|
|
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
|
);
|
|
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
|
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
|
});
|
|
|
|
test("should handle real-world scenario: action config + user config", () => {
|
|
// This is the exact scenario from the bug report
|
|
const actionConfig = JSON.stringify({
|
|
mcpServers: {
|
|
github_comment: {
|
|
command: "node",
|
|
args: ["github-comment-server.js"],
|
|
},
|
|
github_ci: { command: "node", args: ["github-ci-server.js"] },
|
|
},
|
|
});
|
|
const userConfig = JSON.stringify({
|
|
mcpServers: {
|
|
my_custom_server: { command: "python", args: ["server.py"] },
|
|
},
|
|
});
|
|
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: `--mcp-config '${actionConfig}' --mcp-config '${userConfig}'`,
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
const mcpConfig = JSON.parse(
|
|
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
|
);
|
|
// All servers should be present
|
|
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
|
|
expect(mcpConfig.mcpServers).toHaveProperty("github_ci");
|
|
expect(mcpConfig.mcpServers).toHaveProperty("my_custom_server");
|
|
});
|
|
});
|
|
|
|
describe("other extraArgs passthrough", () => {
|
|
test("should pass through json-schema in extraArgs", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: `--json-schema '{"type":"object"}'`,
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.extraArgs?.["json-schema"]).toBe(
|
|
'{"type":"object"}',
|
|
);
|
|
expect(result.hasJsonSchema).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("shell comment stripping", () => {
|
|
test("should parse flags before and after a comment line", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: "--model 'claude-haiku'\n# comment\n--allowed-tools 'Edit'",
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit"]);
|
|
});
|
|
|
|
test("should parse flags correctly when no comments are present", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: "--model 'claude-haiku'",
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
|
});
|
|
|
|
test("should not strip inline # that appears inside a quoted value", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: "--model 'claude-haiku' --prompt 'use color #ff0000'",
|
|
};
|
|
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
|
expect(result.sdkOptions.extraArgs?.["prompt"]).toBe("use color #ff0000");
|
|
});
|
|
});
|
|
|
|
describe("environment variables passthrough", () => {
|
|
test("should include OTEL environment variables in sdkOptions.env", () => {
|
|
// Set up test environment variables
|
|
const originalEnv = { ...process.env };
|
|
process.env.CLAUDE_CODE_ENABLE_TELEMETRY = "1";
|
|
process.env.OTEL_METRICS_EXPORTER = "otlp";
|
|
process.env.OTEL_LOGS_EXPORTER = "otlp";
|
|
process.env.OTEL_EXPORTER_OTLP_PROTOCOL = "http/json";
|
|
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://example.com";
|
|
process.env.OTEL_EXPORTER_OTLP_HEADERS =
|
|
"Authorization=Bearer test-token";
|
|
process.env.OTEL_METRIC_EXPORT_INTERVAL = "10000";
|
|
process.env.OTEL_LOGS_EXPORT_INTERVAL = "5000";
|
|
process.env.OTEL_RESOURCE_ATTRIBUTES = "department=test";
|
|
|
|
try {
|
|
const options: ClaudeOptions = {};
|
|
const result = parseSdkOptions(options);
|
|
|
|
// Verify OTEL env vars are passed through to sdkOptions.env
|
|
expect(result.sdkOptions.env?.CLAUDE_CODE_ENABLE_TELEMETRY).toBe("1");
|
|
expect(result.sdkOptions.env?.OTEL_METRICS_EXPORTER).toBe("otlp");
|
|
expect(result.sdkOptions.env?.OTEL_LOGS_EXPORTER).toBe("otlp");
|
|
expect(result.sdkOptions.env?.OTEL_EXPORTER_OTLP_PROTOCOL).toBe(
|
|
"http/json",
|
|
);
|
|
expect(result.sdkOptions.env?.OTEL_EXPORTER_OTLP_ENDPOINT).toBe(
|
|
"https://example.com",
|
|
);
|
|
expect(result.sdkOptions.env?.OTEL_EXPORTER_OTLP_HEADERS).toBe(
|
|
"Authorization=Bearer test-token",
|
|
);
|
|
expect(result.sdkOptions.env?.OTEL_METRIC_EXPORT_INTERVAL).toBe(
|
|
"10000",
|
|
);
|
|
expect(result.sdkOptions.env?.OTEL_LOGS_EXPORT_INTERVAL).toBe("5000");
|
|
expect(result.sdkOptions.env?.OTEL_RESOURCE_ATTRIBUTES).toBe(
|
|
"department=test",
|
|
);
|
|
} finally {
|
|
// Restore original environment
|
|
process.env = originalEnv;
|
|
}
|
|
});
|
|
|
|
test("should set CLAUDE_CODE_ENTRYPOINT in sdkOptions.env", () => {
|
|
const options: ClaudeOptions = {};
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.env?.CLAUDE_CODE_ENTRYPOINT).toBe(
|
|
"claude-code-github-action",
|
|
);
|
|
});
|
|
|
|
test("should strip ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN from env", () => {
|
|
const originalEnv = { ...process.env };
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_URL =
|
|
"https://token.actions.githubusercontent.com";
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = "secret-token-value";
|
|
|
|
try {
|
|
const options: ClaudeOptions = {};
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(
|
|
result.sdkOptions.env?.ACTIONS_ID_TOKEN_REQUEST_URL,
|
|
).toBeUndefined();
|
|
expect(
|
|
result.sdkOptions.env?.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
|
|
).toBeUndefined();
|
|
} finally {
|
|
process.env = originalEnv;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("settingSources", () => {
|
|
test("should default to ['user'] when not specified", () => {
|
|
const options: ClaudeOptions = {};
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.settingSources).toEqual(["user"]);
|
|
});
|
|
|
|
test("should use direct settingSources input when provided", () => {
|
|
const options: ClaudeOptions = {
|
|
settingSources: "user,project,local",
|
|
};
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.settingSources).toEqual([
|
|
"user",
|
|
"project",
|
|
"local",
|
|
]);
|
|
});
|
|
|
|
test("should use --setting-sources from claudeArgs when no direct input", () => {
|
|
const options: ClaudeOptions = {
|
|
claudeArgs: "--setting-sources user,project",
|
|
};
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.settingSources).toEqual(["user", "project"]);
|
|
expect(result.sdkOptions.extraArgs?.["setting-sources"]).toBeUndefined();
|
|
});
|
|
|
|
test("direct input should take precedence over claudeArgs", () => {
|
|
const options: ClaudeOptions = {
|
|
settingSources: "user",
|
|
claudeArgs: "--setting-sources user,project,local",
|
|
};
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.settingSources).toEqual(["user"]);
|
|
});
|
|
|
|
test("should trim whitespace in comma-separated values", () => {
|
|
const options: ClaudeOptions = {
|
|
settingSources: "user, project , local",
|
|
};
|
|
const result = parseSdkOptions(options);
|
|
|
|
expect(result.sdkOptions.settingSources).toEqual([
|
|
"user",
|
|
"project",
|
|
"local",
|
|
]);
|
|
});
|
|
});
|
|
});
|