From f5cd0f0cbb1ab713ce9e562f7e47b1ee1ab57b01 Mon Sep 17 00:00:00 2001 From: HomeLab Agent Date: Fri, 9 Oct 2026 09:41:38 -0400 Subject: [PATCH 1/2] feat(core): pass agent `settings` through at the flag tier New optional agent field `settings`, an object of Claude Code settings. `toSDKOptions` hands it to the Agent SDK's `settings` option; the CLI runtime emits the same thing as `--settings `, so a config behaves the same on either runtime. Omitted entirely when unset or empty. Flag settings outrank every file source but managed policy and apply whatever `setting_sources` loads, so this is the channel for keys a project's checked-in `.claude/settings.json` is not trusted to set. The motivating one is `autoMemoryDirectory`, which Paddock needs to set per agent (edspencer/paddock#955): Claude Code ignores it in projectSettings, herdctl never loads userSettings for SDK agents, and the `CLAUDE_COWORK_MEMORY_PATH_OVERRIDE` env alternative disables the auto-memory write exemption, so there was no working route. Tests drive addAgent (the strict schema) -> toSDKOptions -> query() options, the CLI argv, and a compile-time contract against the SDK's own `Options["settings"]`. Mutation-checked: removing either passthrough line fails them. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-settings-passthrough.md | 5 +++ .../docs/configuration/agent-config.md | 15 +++++++ packages/core/src/config/schema.ts | 11 +++++ .../mcp-and-plugin-passthrough.test.ts | 42 +++++++++++++++++++ .../__tests__/sdk-type-contract.test.ts | 8 ++++ .../runtime/__tests__/cli-runtime.test.ts | 39 +++++++++++++++++ .../core/src/runner/runtime/cli-runtime.ts | 6 +++ packages/core/src/runner/sdk-adapter.ts | 7 ++++ packages/core/src/runner/types.ts | 6 +++ 9 files changed, 139 insertions(+) create mode 100644 .changeset/agent-settings-passthrough.md diff --git a/.changeset/agent-settings-passthrough.md b/.changeset/agent-settings-passthrough.md new file mode 100644 index 00000000..d320a7f4 --- /dev/null +++ b/.changeset/agent-settings-passthrough.md @@ -0,0 +1,5 @@ +--- +"@herdctl/core": minor +--- + +Add an agent-level `settings` field: Claude Code settings passed through at the flag tier, as the Agent SDK's `settings` option or `--settings ` on the CLI runtime. It lets an embedder set a key per agent that a project's checked-in `.claude/settings.json` is not trusted to set, such as `autoMemoryDirectory` (edspencer/paddock#955). diff --git a/docs/src/content/docs/configuration/agent-config.md b/docs/src/content/docs/configuration/agent-config.md index d2b82112..c8f7990e 100644 --- a/docs/src/content/docs/configuration/agent-config.md +++ b/docs/src/content/docs/configuration/agent-config.md @@ -82,6 +82,7 @@ max_turns: 100 | `setting_sources` | string[] | No | Where Claude discovers settings: `user`, `project`, `local` | | `mcp_servers` | object | No | MCP server configurations | | `plugins` | array | No | Claude Code plugins to load (paths or `{type, path}` objects) | +| `settings` | object | No | Claude Code settings applied at the flag tier (`--settings`) | | `chat` | object | No | Chat integration settings | | `docker` | object | No | Docker execution settings | | `runtime` | string | No | Runtime type: `"sdk"` (default) or `"cli"` | @@ -774,6 +775,20 @@ Fleet [`defaults.plugins`](/configuration/fleet-config/#defaultsplugins) applies For a [dockerized agent](/configuration/docker/) the path must resolve *inside* the container, so the plugin directory has to be mounted there. ::: +### settings + +Claude Code [settings](https://docs.claude.com/en/docs/claude-code/settings) for this agent, applied at the **flag tier**: passed to the Agent SDK's `settings` option, or as `--settings ` on the [CLI runtime](#runtime). The object is passed through verbatim, and Claude Code validates the keys. + +```yaml +settings: + autoMemoryDirectory: /srv/agents/coder/memory + effortLevel: high +``` + +Flag settings outrank every settings file except managed policy, and they apply whatever [`setting_sources`](#setting_sources) loads. That makes them the place for a key a project's checked-in `.claude/settings.json` is not trusted to set: Claude Code ignores `autoMemoryDirectory` there, but honours it here. + +Agent-level only; there is no fleet `defaults.settings`. + ### model Override the Claude model for this agent. diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 62af2dc9..db1ec742 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -1075,6 +1075,17 @@ export const AgentConfigSchema = z * needs mounting. */ plugins: z.array(PluginSchema).optional(), + /** + * Claude Code settings applied at the flag tier — the SDK's `settings` + * option, or `--settings ` on the CLI runtime. + * + * Flag settings outrank every file source except managed policy, and they + * apply whatever `setting_sources` loads, so this is how an embedder sets a + * key per agent that a project's checked-in `.claude/settings.json` is not + * trusted to set (e.g. `autoMemoryDirectory`). Passed through verbatim; + * Claude Code validates the keys. + */ + settings: z.record(z.string(), z.unknown()).optional(), chat: AgentChatSchema.optional(), hooks: AgentHooksSchema.optional(), docker: AgentDockerSchema.optional(), diff --git a/packages/core/src/runner/__tests__/mcp-and-plugin-passthrough.test.ts b/packages/core/src/runner/__tests__/mcp-and-plugin-passthrough.test.ts index fc3dfccb..a80dd815 100644 --- a/packages/core/src/runner/__tests__/mcp-and-plugin-passthrough.test.ts +++ b/packages/core/src/runner/__tests__/mcp-and-plugin-passthrough.test.ts @@ -274,6 +274,48 @@ describe("MCP server + plugin passthrough (#444, #445)", () => { }); }); + // =========================================================================== + // settings — flag-tier settings passthrough + // =========================================================================== + + describe("settings reach SDKQueryOptions", () => { + it("survives addAgent (the strict schema) and reaches the SDK query() options", async () => { + const agent = await addAndResolve({ + name: "flag-settings", + settings: { autoMemoryDirectory: "/data/memory" }, + }); + + expect(agent.settings).toEqual({ autoMemoryDirectory: "/data/memory" }); + expect(toSDKOptions(agent).settings).toEqual({ autoMemoryDirectory: "/data/memory" }); + + const runtime = new SDKRuntime(); + for await (const _ of runtime.execute({ prompt: "hi", agent })) { + // mocked query() yields nothing + } + + expect(queryCalls).toHaveLength(1); + expect(queryCalls[0].settings).toEqual({ autoMemoryDirectory: "/data/memory" }); + }); + + it("omits the key when the agent sets none, or an empty object", async () => { + expect(toSDKOptions(await addAndResolve({ name: "no-settings" }))).not.toHaveProperty( + "settings", + ); + expect( + toSDKOptions(await addAndResolve({ name: "empty-settings", settings: {} })), + ).not.toHaveProperty("settings"); + }); + + it("does not alias the agent's object, so a runtime cannot mutate config", async () => { + const agent = await addAndResolve({ name: "isolated-settings", settings: { a: 1 } }); + + const sdkOptions = toSDKOptions(agent); + sdkOptions.settings!.a = 2; + + expect(agent.settings).toEqual({ a: 1 }); + }); + }); + // =========================================================================== // #444 second blocker — the settingSources lever that already exists // =========================================================================== diff --git a/packages/core/src/runner/__tests__/sdk-type-contract.test.ts b/packages/core/src/runner/__tests__/sdk-type-contract.test.ts index 27f20c68..c8fc3688 100644 --- a/packages/core/src/runner/__tests__/sdk-type-contract.test.ts +++ b/packages/core/src/runner/__tests__/sdk-type-contract.test.ts @@ -39,6 +39,14 @@ type _OurPluginsMatchTheOption = Extends< NonNullable >; +// --- settings: flag-tier settings object ---------------------------------- + +// Our `settings` must be something the SDK's `settings` option accepts. +type _OurSettingsMatchTheOption = Extends< + NonNullable, + NonNullable +>; + // --- #445: MCP server transports ------------------------------------------- // Every transport the SDK's serializable configs declare must be expressible in diff --git a/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts b/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts index de10b2ae..ef02e98f 100644 --- a/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts +++ b/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts @@ -465,6 +465,45 @@ describe("CLIRuntime plugins (--plugin-dir)", () => { }); }); +describe("CLIRuntime flag settings (--settings)", () => { + async function captureArgs(agent: Record): Promise { + let spawnedArgs: string[] = []; + + const runtime = new CLIRuntime({ + processSpawner: ((args: string[]) => { + spawnedArgs = args; + return makeSubprocess() as never; + }) as never, + }); + + for await (const _message of runtime.execute({ + prompt: "Hello", + agent: { name: "settings-agent", configPath: "/tmp/agent.yaml", ...agent } as never, + })) { + // drain + } + + return spawnedArgs; + } + + it("serialises the agent's settings into one --settings argument", async () => { + const args = await captureArgs({ + settings: { autoMemoryDirectory: "/data/memory", effortLevel: "high" }, + }); + + expect(args.filter((a) => a === "--settings")).toHaveLength(1); + expect(JSON.parse(args[args.indexOf("--settings") + 1])).toEqual({ + autoMemoryDirectory: "/data/memory", + effortLevel: "high", + }); + }); + + it("emits no --settings when the agent sets none, or an empty object", async () => { + expect(await captureArgs({})).not.toContain("--settings"); + expect(await captureArgs({ settings: {} })).not.toContain("--settings"); + }); +}); + describe("CLIRuntime session fork (--fork-session)", () => { beforeEach(() => { watchMessages.length = 0; diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index edfb9b11..2b0b5c66 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -291,6 +291,12 @@ export class CLIRuntime implements RuntimeInterface { } } + // Flag-tier settings — the same `--settings` the Agent SDK emits for its own + // `settings` option, so an agent's `settings` behaves the same on either runtime. + if (options.agent.settings && Object.keys(options.agent.settings).length > 0) { + args.push("--settings", JSON.stringify(options.agent.settings)); + } + // Collect MCP servers for --mcp-config. Claude CLI expects // {"mcpServers": { ... }} (same shape as .mcp.json). Declared servers are // transformed from agent config format; injected servers are added below. diff --git a/packages/core/src/runner/sdk-adapter.ts b/packages/core/src/runner/sdk-adapter.ts index 80ddae3b..75fb68b9 100644 --- a/packages/core/src/runner/sdk-adapter.ts +++ b/packages/core/src/runner/sdk-adapter.ts @@ -227,6 +227,13 @@ export function toSDKOptions( result.plugins = agent.plugins.map((plugin) => ({ ...plugin })); } + // Flag-tier settings. Omitted when the agent sets none, so the SDK passes no + // `--settings` at all. Shallow-copied for the same no-aliasing reason as + // `allowedTools` above. + if (agent.settings && Object.keys(agent.settings).length > 0) { + result.settings = { ...agent.settings }; + } + // Max turns limit (agent-level or session-level) const maxTurns = agent.max_turns ?? agent.session?.max_turns; if (maxTurns !== undefined) { diff --git a/packages/core/src/runner/types.ts b/packages/core/src/runner/types.ts index 3e9342dd..a1e4f74f 100644 --- a/packages/core/src/runner/types.ts +++ b/packages/core/src/runner/types.ts @@ -273,6 +273,12 @@ export interface SDKQueryOptions { * (edspencer/herdctl#444). */ plugins?: SDKPluginConfig[]; + /** + * Flag-tier Claude Code settings, from the agent's `settings` config. Set by + * `toSDKOptions` only when the agent declares a non-empty object; the SDK + * hands it to Claude Code as `--settings`. + */ + settings?: Record; resume?: string; forkSession?: boolean; /** Maximum number of agentic turns before stopping */ From ca16e6cdebb9f81ca265e81167ed34395aa0426a Mon Sep 17 00:00:00 2001 From: HomeLab Agent Date: Fri, 9 Oct 2026 12:46:36 -0400 Subject: [PATCH 2/2] fix(core): keep --settings out of the debug log; document the exposure Adversarial review of #473: the CLI runtime's debug line printed the whole argv, so the new --settings JSON (which can carry resolved secrets via env, apiKeyHelper or interpolated ${VAR}s) reached the log. Redact its value; a test asserts the secret never appears and fails without the redaction. Docs: warn that settings travel as argv on both runtimes, say Claude Code ignores bad keys silently rather than validating them, and note that YAML interpolation has no escape for Claude Code's own ${...} syntax. Co-Authored-By: Claude Opus 5.5 --- .../content/docs/configuration/agent-config.md | 10 +++++++++- .../runtime/__tests__/cli-runtime.test.ts | 18 ++++++++++++++++++ .../core/src/runner/runtime/cli-runtime.ts | 6 +++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/configuration/agent-config.md b/docs/src/content/docs/configuration/agent-config.md index c8f7990e..ab793efa 100644 --- a/docs/src/content/docs/configuration/agent-config.md +++ b/docs/src/content/docs/configuration/agent-config.md @@ -777,7 +777,7 @@ For a [dockerized agent](/configuration/docker/) the path must resolve *inside* ### settings -Claude Code [settings](https://docs.claude.com/en/docs/claude-code/settings) for this agent, applied at the **flag tier**: passed to the Agent SDK's `settings` option, or as `--settings ` on the [CLI runtime](#runtime). The object is passed through verbatim, and Claude Code validates the keys. +Claude Code [settings](https://docs.claude.com/en/docs/claude-code/settings) for this agent, applied at the **flag tier**: passed to the Agent SDK's `settings` option, or as `--settings ` on the [CLI runtime](#runtime). The object is passed through verbatim. Claude Code ignores an unknown key or an invalid value without reporting it, so check that a setting took effect rather than assuming it did. ```yaml settings: @@ -789,6 +789,14 @@ Flag settings outrank every settings file except managed policy, and they apply Agent-level only; there is no fleet `defaults.settings`. +:::caution[Don't put secrets in `settings`] +On both runtimes the object reaches Claude Code as a `--settings` command-line argument, so any local user can read it in `/proc//cmdline`. herdctl redacts it from its own debug log, but it can't hide the argv. Keep credentials out of it, including `env` entries and `${VAR}` values that interpolation would resolve. +::: + +:::note[`${...}` is interpolated] +In a YAML config, herdctl interpolates `${VAR}` and `${VAR:-default}` inside `settings` like any other field, and an unset variable with no default is an error. There is no escape syntax, so a value that must reach Claude Code with its own `${...}` intact, such as `${CLAUDE_PROJECT_DIR}` in a hook command, can't be written here. Put it in a project settings file instead, or register the agent programmatically, which skips interpolation. +::: + ### model Override the Claude model for this agent. diff --git a/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts b/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts index ef02e98f..40dbed9c 100644 --- a/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts +++ b/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts @@ -498,6 +498,24 @@ describe("CLIRuntime flag settings (--settings)", () => { }); }); + it("never writes the settings JSON to the debug log", async () => { + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const prev = process.env.HERDCTL_LOG_LEVEL; + process.env.HERDCTL_LOG_LEVEL = "debug"; + try { + await captureArgs({ settings: { env: { TOKEN: "s3cret-value" } } }); + const logged = [...debug.mock.calls, ...log.mock.calls].flat().map(String).join("\n"); + expect(logged).toContain("--settings "); + expect(logged).not.toContain("s3cret-value"); + } finally { + if (prev === undefined) delete process.env.HERDCTL_LOG_LEVEL; + else process.env.HERDCTL_LOG_LEVEL = prev; + debug.mockRestore(); + log.mockRestore(); + } + }); + it("emits no --settings when the agent sets none, or an empty object", async () => { expect(await captureArgs({})).not.toContain("--settings"); expect(await captureArgs({ settings: {} })).not.toContain("--settings"); diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index 2b0b5c66..f08c9dff 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -393,7 +393,11 @@ export class CLIRuntime implements RuntimeInterface { // Note: Prompt is NOT added to args - it's provided via stdin (see processSpawner call below) // DEBUG: Log the command being executed - logger.debug(`Executing command: claude ${args.join(" ")}`); + // `--settings` carries the agent's flag-tier settings verbatim, which can + // hold resolved secrets (`env`, `apiKeyHelper`, interpolated `${VAR}`s), so + // its value never reaches the log. + const loggedArgs = args.map((arg, i) => (args[i - 1] === "--settings" ? "" : arg)); + logger.debug(`Executing command: claude ${loggedArgs.join(" ")}`); logger.debug(`Prompt: ${options.prompt}`); // Track process and watcher for cleanup