Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,8 @@ Conductor:
- The SDK applies it as the `claude` subprocess's cwd (`_internal/transport/subprocess_cli.py` as of 0.2.87 passes it to `open_process` and sets `PWD`), so stdio MCP servers pick it up by **inheriting** it from that subprocess. There is deliberately no per-server stamping as in `copilot.py::_mcp_servers_for_cwd`: the SDK's `McpStdioServerConfig` has no cwd field, so `_translate_mcp_servers` is left alone. Inheritance is a property of the CLI binary, not of the SDK, so it is documented rather than asserted by a test.
- The path is passed **verbatim** — `WorkflowEngine._resolve_agent_working_dir` has already rendered, absolutized, normalised, and existence-checked it, and re-resolving here would collapse the symlink aliases the engine preserves on purpose. The `ClaudeAgentOptions(...)` construction lives **inside** `execute`'s `try` so the `os.getcwd()` fallback can't escape as a bare `OSError` when the process cwd has been deleted (`copilot.py` resolves its cwd inside its try for the same reason).
- There is no provider-side `is_dir()` guard: a directory that vanishes after the engine's check surfaces as the SDK's `CLIConnectionError("Working directory does not exist: <path>")`, wrapped in `ProviderError`. That is only defensible because `_classify_startup_failure` special-cases it — `CLIConnectionError` otherwise yields firewall/binary advice and `is_retryable=True`, which is wrong for all three launch failures (missing dir; path is a file → `ENOTDIR`; unreadable dir → `EACCES`; the latter two reach the SDK's generic "Failed to start Claude Code" arm, not its dedicated one).
- Knock-on effects: cwd is the project key for the CLI's on-disk transcript directory, and it is where a project `.mcp.json` and `.claude/` tree would be looked for. The unconditional `strict_mcp_config=True` stops a `.mcp.json` there from injecting undeclared servers, and `setting_sources=[]` (empty by default; see **Skills** below) stops the CLI loading `CLAUDE.md`, project settings, and hooks from it — so cwd no longer drags ambient instructions in unless the workflow opts in via `runtime.provider.setting_sources`, which is exactly a request to load them from that directory. `add_dirs` (the SDK's `--add-dir` passthrough) is a separate axis Conductor does not set.
- Knock-on effects: cwd is the project key for the CLI's on-disk transcript directory, and it is where a project `.mcp.json` and `.claude/` tree would be looked for. The unconditional `strict_mcp_config=True` stops a `.mcp.json` there from injecting undeclared servers, and `setting_sources=[]` (empty by default; see **Skills** below) stops the CLI loading `CLAUDE.md`, project settings, and hooks from it — so cwd no longer drags ambient instructions in unless the workflow opts in via `runtime.provider.setting_sources`, which is exactly a request to load them from that directory. `add_dirs` (the SDK's `--add-dir` passthrough) is a separate axis, carrying the per-agent `settings_dir` and nothing else — see **Target-repository skills** below.
- **Target-repository skills** (`settings_dir`): the per-agent `AgentDef.settings_dir` is the **only** source of `ClaudeAgentOptions.add_dirs`, and it selects which directory's *project* settings tier contributes skills. It exists because cwd was doing two conflicting jobs on this provider. The CLI supports the MCP Roots protocol and advertises exactly one root — its cwd — so `@modelcontextprotocol/server-filesystem` **discards the directories in its own argv** and permits cwd alone; cwd is simultaneously what the `project` tier resolves against. Narrowing cwd onto a target repository to reach its skills therefore narrowed the agent's only MCP root below any sibling path the step still had to read. Do not derive `add_dirs` from a server's directory arguments to compensate: `--add-dir` takes no part in Roots negotiation, it widens the CLI's own file tools, and doing so would also silently widen skill discovery to directories the author named as data. What `add_dirs` *does* do, measured: a named directory's `.claude/skills` become listed and invocable with cwd elsewhere entirely — and only those, not `CLAUDE.md`, `.claude/rules/*.md`, `.claude/settings.json` (so no `env` and no `hooks` — the hooks negative is measured with a side-effect probe whose control fires) or `.claude/agents`, which all keep following cwd. That makes it the *skills third* of what a cwd-resolved `project` tier loads, not a replacement for it. It carries a second, **unconditional** effect the skills framing hides: `add_dirs` is "additional directories Claude can access" per the SDK's own contract, so a `settings_dir` widens the model's built-in `Read`/`Edit`/`Bash` to that tree with no settings tier enabled at all (measured against `claude` CLI 2.1.263 at `permission_mode: "default"` with `setting_sources` unset: a read outside cwd is refused without it and succeeds with it; an agent omitting `tools:` runs under `bypassPermissions`, where the grant is unobservable because reads already succeed). It does not widen what an MCP server permits. `capabilities.py::settings_dir` gates the field and `config/validator.py` errors against a provider that cannot apply it — enforced **twice**, with `AgentExecutor._reject_unsupported_settings_dir` repeating it at run time, because `conductor run` never calls the static validator — warning when the agent's session will not enable the `project` tier (also twice: `config/validator.py` at validate, and `claude_agent_sdk.py::execute` at run time, since the filesystem grant applies even when the skills half no-ops; the run-time half is latched per `(resolved directory, cause)` — not per agent, because a `for_each` member is renamed per item and would otherwise warn once per iteration, and not per directory alone, because the remedy differs by cause and one line would prescribe a fix wrong for the agent it does not name; two agents sharing a directory *and* a cause do collapse to one line). The opt-out remedy assumes the `project` tier is otherwise present: an agent with `skills: []` under a `user`/`local`-only tier is told to remove the opt-out, which is necessary but not sufficient, so that author reaches a working config in two steps rather than one. Deliberate -- distinguishing it needs a third cause value and a wider latch key to serve a combination requiring two unusual settings at once. `WorkflowEngine._resolve_agent_directory` resolves both fields so they cannot drift, and `settings_dir` is per-agent only (no `runtime.` counterpart: the repository whose conventions apply is what varies between steps). `tests/test_integration/test_mcp_roots_negotiation.py` pins the negotiation rule itself against the real server with no LLM — two runs differing only in whether the client advertises `roots` — since every option here rests on it (marked `real_api`: it fetches the server from npm and pins *upstream's* behaviour, not Conductor's). The resolved value is emitted on `agent_started` / `parallel_agent_started` / `for_each_agent_started` alongside `working_dir`, because it is a trust decision — another repository's conventions plus a filesystem grant — and one the run output would otherwise never mention.
- **Skills** (issue #352): `supports_native_skills=True`. Skills are enabled through the SDK, not prompt injection, and three options move together in `execute`:
- `plugins=[{"type": "local", "path": <plugin root>}]` + `skills=["<plugin>:<skill>"]`. The SDK has no skill-*directory* surface, so `_resolve_skill_plugins` maps each directory back to the plugin that owns it via `skills/registry.py::resolve_skill_plugin`. That resolver is deliberately strict, because every one of these mistakes otherwise produces a name the CLI silently resolves to nothing: it bounds the upward walk (`_PLUGIN_SEARCH_DEPTH`), requires the skill to actually live under the candidate's `skills/` directory, requires `SKILL.md` to exist and its frontmatter `name` to equal the directory name (the CLI resolves by frontmatter name; Conductor sends the directory name), and rejects names outside `[A-Za-z0-9_.-]+` since they are joined into a comma-delimited `--allowedTools` value. A skill under no plugin root returns `None`; a plugin that is present but unusable raises `SkillPluginError`, which the provider re-raises as a `ProviderError` carrying the real reason rather than a blanket "not part of a plugin". Two plugins claiming one qualified name are refused too — deduping the clash away would drop a declared skill. All of it is `is_retryable=False`: these never become valid on a retry, and a checkout path containing "connection" would otherwise trip the retryability heuristic. Note providers are constructed lazily, so this surfaces when the first agent on this provider runs, **not** at `conductor validate`.
- `setting_sources=[]` **by default**, for the same reason `strict_mcp_config=True` is unconditional a few lines away — but opt-in per workflow via `runtime.provider.setting_sources` (`user`/`project`/`local`, issue #501). Left unset, the CLI loads user, project, and local settings, which between them bring in ambient skills, `CLAUDE.md`, and hooks the workflow never declared, varying by machine and launch directory. Conductor surfaces instruction files through its own opt-in `--workspace-instructions`; settings and hooks have no equivalent. `skills=[]` and `skills=None` are **not** interchangeable upstream: `None` means "CLI defaults apply", and setting `skills` while leaving `setting_sources` at `None` makes the SDK re-default it to `["user", "project"]` — so the two options are coupled and dropping either re-opens the issue. The `[]` is also invisible in argv (it travels in the SDK's `initialize` control request), which is why the argv-based tests are paired with options-level assertions. The list is a context filter, not a sandbox: undeclared skills are hidden from the model's listing and rejected by the `Skill` tool, but their files stay readable. Opting a workflow in (`runtime.provider.setting_sources: [project]`, the motivating case being an agent whose `working_dir` is a *target* repo shipping its own `.claude/skills` — the CLI has `--plugin-dir` but no `--skill-dir`) loads that tier **with its hooks**, so it is only for repositories trusted as much as the workflow itself, and the field is workflow-global while `working_dir` is per agent. Two couplings follow: `_resolve_skill_filter` resolves `skills` to `"all"` when a tier is enabled and the workflow named none itself (tier-discovered skills never pass through `skill_names`, so `[]` would load the repo's skills and then hide every one), and a per-agent `skills: []` opts that agent out of the tier entirely — hooks included — keeping it the one opt-out.
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
entirely. See
[`examples/claude-agent-sdk-setting-sources.yaml`](examples/claude-agent-sdk-setting-sources.yaml).

- **Per-agent `settings_dir` on `claude-agent-sdk`** (#513) — selects which
directory's `project` settings tier supplies an agent's **skills**,
independently of `working_dir`. The CLI advertises exactly one MCP root —
its cwd — and a filesystem MCP server that sees a Roots-capable client
discards the directories in its own argv, so pointing `working_dir` at a
target repository to pick up its skills also narrowed the agent's only MCP
root onto it. `settings_dir` splits the two, letting cwd stay wide enough
for every path the agent must read. It carries a second, unconditional
effect: `add_dirs` widens the model's built-in `Read`/`Edit`/`Bash` to that
tree regardless of any settings tier, though no Conductor configuration
reaches a permission mode where that is observable today. Only the skills
of that directory travel — not `CLAUDE.md`, `.claude/rules/*.md`,
`.claude/settings.json` or `.claude/agents`, all measured. Refused at
`conductor validate` *and* at run time on a provider that cannot apply it;
a `settings_dir` whose `project` tier is not enabled warns in both places
too, since the filesystem grant applies even when the skills half no-ops.
Reported on the agent lifecycle events so the grant is auditable. See
[`examples/claude-agent-sdk-settings-dir.yaml`](examples/claude-agent-sdk-settings-dir.yaml).

### Fixed

- **Context compaction window guard against token-dense drift** (#507) — the
Expand Down Expand Up @@ -76,6 +95,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`reason: "estimate_unavailable"`) rather than vanishing into stderr. See
[Workflow Syntax → Context Compaction](docs/workflow-syntax.md#context-compaction).

### Changed

- **A `working_dir` or `settings_dir` template that renders empty is now an
error** (#513). Previously an empty render resolved to the workflow file's
own directory — `Path("")` is `Path(".")`, which is not absolute, so it was
joined onto that directory and passed the existence check — and the agent
ran there. A value meaning "nothing" silently becoming something real is
the defect; for `settings_dir` it would also have granted the model access
to the workflow's own tree. Both fields now fail before the provider call,
naming the field and the template it came from.

## [0.1.36](https://github.com/microsoft/conductor/compare/v0.1.35...v0.1.36) - 2026-09-02

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/providers/experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ adopting one does not inflate the install surface for others.

| Provider | Upstream pin | Maintainer | Capability carve-outs |
|---|---|---|---|
| `claude-agent-sdk` | `claude-agent-sdk>=0.2.82` | `@lesandiz (best-effort)` | no `workflow_tools_passthrough`, no `reasoning_effort`, `prompt_injection` structured output, no `checkpoint_resume` (agents without a `session_key` carry no session state across a resume). Supports `mcp_tools` as of [#335](https://github.com/microsoft/conductor/issues/335), except that a narrowing per-server `tools:` filter is refused (no SDK equivalent). Supports `working_dir` as of [#348](https://github.com/microsoft/conductor/issues/348); the CLI would load `CLAUDE.md` and `.claude/settings*.json` from that directory, but `setting_sources` is empty by default as of [#352](https://github.com/microsoft/conductor/issues/352) so ambient instructions, settings, hooks, and skills are not inherited unless a workflow opts in via `runtime.provider.setting_sources` ([#501](https://github.com/microsoft/conductor/issues/501)) — which loads the named tiers **including their hooks**, so only for repositories trusted as much as the workflow. Declares `session_continuity`: an agent with a `session_key` reuses one Claude session across executions, and the session map survives `conductor resume` — see [Session Continuity](../workflow-syntax.md#session-continuity-session_key). |
| `claude-agent-sdk` | `claude-agent-sdk>=0.2.82` | `@lesandiz (best-effort)` | no `workflow_tools_passthrough`, no `reasoning_effort`, `prompt_injection` structured output, no `checkpoint_resume` (agents without a `session_key` carry no session state across a resume). Supports `mcp_tools` as of [#335](https://github.com/microsoft/conductor/issues/335), except that a narrowing per-server `tools:` filter is refused (no SDK equivalent). Supports `working_dir` as of [#348](https://github.com/microsoft/conductor/issues/348); the CLI would load `CLAUDE.md` and `.claude/settings*.json` from that directory, but `setting_sources` is empty by default as of [#352](https://github.com/microsoft/conductor/issues/352) so ambient instructions, settings, hooks, and skills are not inherited unless a workflow opts in via `runtime.provider.setting_sources` ([#501](https://github.com/microsoft/conductor/issues/501)) — which loads the named tiers **including their hooks**, so only for repositories trusted as much as the workflow. Which directory that `project` tier reads **skills** from is chosen per agent with `settings_dir` (cwd alone governs the CLI's sole MCP root, so the two are deliberately separate) — see [Target-Repository Skills](../workflow-syntax.md#target-repository-skills-settings_dir). Declares `session_continuity`: an agent with a `session_key` reuses one Claude session across executions, and the session map survives `conductor resume` — see [Session Continuity](../workflow-syntax.md#session-continuity-session_key). |
| `hermes` | `hermes-agent` | `(community contribution)` | no `mcp_tools`, `prompt_injection` structured output, no `working_dir` |
| `aca` | `azure-identity>=1.19.0` | `(unassigned)` | no `workflow_tools_passthrough` (the wrapped in-container `CopilotProvider` never applies the `tools:` allowlist to the SDK session), no `working_dir` (only the separate, container-relative `sandbox.working_dir` is honored — not the generic host-resolved field), `prompt_injection` structured output (inherits the inner Copilot provider), no `checkpoint_resume` (ephemeral sandbox sessions, no volume mount). Declares `interrupt`/`max_session_seconds` as `True`, but the shipped runner MVP doesn't fully back either yet — see [Known Gaps](./aca.md#known-gaps-runner-mvp). |

Expand Down
Loading
Loading