[ON HOLD — upstreamed as microsoft/conductor#514] PDA-95 Separate the project settings tier from cwd with settings_dir - #6
[ON HOLD — upstreamed as microsoft/conductor#514] PDA-95 Separate the project settings tier from cwd with settings_dir#6throup wants to merge 4 commits into
Conversation
…s_dir An agent's cwd was doing two unrelated jobs on this provider, and they conflict. The `claude` 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` settings tier resolves against. Pointing `working_dir` at 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; widening it back lost the repository's conventions. `_stdio_path_args` existed to prevent exactly this, forwarding every stdio server's directory args into `add_dirs` with a docstring asserting it "restores the declared scope". It cannot: `--add-dir` takes no part in Roots negotiation, it widens the CLI's own file tools. Measured, cwd alone is the effective allowlist whether or not the declared roots are also passed. That helper is removed rather than documented as ineffective, because it also had a live cost -- deriving skill discovery from server arguments grants conventions from directories the author named as data. Add a per-agent `settings_dir`, which is now the only source of `add_dirs`. Skills are discovered from cwd *and* from `settings_dir`, so cwd can stay wide. Measured against the real CLI and through the SDK surface the provider uses: a named directory contributes its `.claude/skills` (listed and invocable) and nothing else -- not CLAUDE.md, `.claude/settings.json` (so no env, no hooks) or `.claude/agents`, which keep following cwd. That asymmetry cuts favourably: a target repository's skills arrive without its hooks. `WorkflowEngine._resolve_agent_directory` resolves both fields so they cannot drift. `settings_dir` is per-agent only -- the repository whose conventions apply is what varies between steps -- and is rejected on every step type with no LLM session, where accepting it silently would suggest conventions had loaded when none had. Tests: the negotiation rule itself is pinned against the real server with no LLM (`test_mcp_roots_negotiation.py`), two runs differing only in whether the client advertises `roots`, since every option here rests on that precedence. `grep add_dir tests/` previously returned nothing, which is the most direct explanation for how an ineffective mitigation shipped and stayed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r hooks Measured, having previously only tested skills: a directory named via `add_dirs` contributes its `.claude/skills` and nothing else. `CLAUDE.md`, `.claude/rules/*.md` (symlinked or not), `.claude/settings.json` (so `env` and `hooks`) and `.claude/agents` all follow cwd. Two runs against the real CLI, differing only in cwd, with a fixture whose `.claude/rules` is a symlink to `.agents/rules` and whose `CLAUDE.md` never mentions it: cwd=repo, no --add-dir -> RULE=set HOOK=set CLAUDEMD=set cwd=wide, --add-dir repo -> RULE=NONE HOOK=NONE CLAUDEMD=NONE, skill=yes So `settings_dir` is the *skills portion* of a project tier, not a cwd-independent way to load one, and it does not compose with `working_dir` into "everything, anywhere": an agent needing a repository's rules or instructions as well as a cwd wide enough for its MCP servers cannot get both from these fields, because one directory cannot be narrow and wide at once. The earlier docs named settings.json and agents but omitted rules, which is the omission most likely to mislead someone choosing between the two fields. Adds a test pinning that a `settings_dir` is never promoted into `cwd` to widen what it loads -- that would hand the agent the narrow directory as its sole MCP root, the exact defect the field exists to avoid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Full measurements, per-file rationale, and the two wrong turns this went throughHow the mechanism was establishedEvery claim below is from a run, not from reading source. The provider's own docstring asserted the opposite of the observed behaviour, so reading it is what produced the original defect. Can the CLI advertise more than one MCP root? No.
What
|
repoconv in the skill listing |
|
|---|---|
with --add-dir exp/rootB |
yes |
| without it (control) | no |
Invocation, not just listing: "Use the repoconv skill and tell me its magic marker" → ROOTB-CONVENTIONS-LOADED.
Additive, not a replacement. cwd = cwdC (own skill pandconv) + --add-dir rootB → both pandconv and repoconv listed.
Everything else follows cwd. One run, cwd = exp, --add-dir exp/rootB:
RULE=NONE; HOOK=NONE; CLAUDEMD=NONE; SKILL=yes
echo $ROOTB_SETTINGS_MARKER → empty. Baseline for comparison, cwd = rootB, no --add-dir:
RULE=ROOTB-RULE-LOADED; HOOK=ROOTB-HOOK-LOADED; CLAUDEMD=ROOTB-CLAUDEMD
All properties at once, plus the bug's negative control. Real filesystem MCP server declared with both roots:
| cwd | --add-dir |
list_allowed_directories |
read rootB/target.txt |
repoconv |
|---|---|---|---|---|
exp (common parent) |
rootB |
exp |
hello-from-rootB |
loaded |
rootA (a declared root) |
rootB |
rootA |
Access denied … not in …/rootA |
— |
Row 1 is the fix working. Row 2 reproduces the reported denial and confirms --add-dir does not widen MCP scope.
Through the SDK, not only the CLI. Row 1 reproduced via ClaudeSDKClient with cwd=exp, add_dirs=[exp/rootB], setting_sources=["project"], skills="all" — the exact option surface providers/claude_agent_sdk.py builds. Identical on all three assertions.
Two wrong turns, recorded because each looked correct
1. "argv roots survive Roots negotiation." My first hand-rolled probe sent tools/call immediately after replying to roots/list and got both declared roots back — the opposite of the truth. The server swaps its allowlist in the continuation of its own listRoots() await, so the call raced the swap and read the pre-swap list. Its stderr said Updated allowed directories from MCP roots: 1 valid directories while the tool answered with two. The test now waits for that line, and the reason is a comment in it.
2. "settings_dir replaces working_dir for target-repo conventions." I tested skills, found them carried, and wrote the docs as though the rest followed. It does not — see the rules/hooks/CLAUDE.md table above. The second commit is that correction; the docs had named settings.json and agents but omitted rules, which is the omission most likely to mislead someone choosing between the fields.
Per-file rationale
| File | Why |
|---|---|
providers/claude_agent_sdk.py |
Removes _stdio_path_args; add_dirs becomes [agent.settings_dir] or []. [] not None: the SDK types the field as list, and None merely happens to be falsy. |
config/schema.py |
The field, its docstring, and rejection on the seven step types with no LLM session. Also corrects ProviderSettings.setting_sources' docstring, which asserted the cwd coupling this removes. |
engine/workflow.py |
Extracts _resolve_agent_directory so both fields share rendering/absolutising/existence-checking and cannot drift. settings_dir is per-agent only — no runtime. counterpart, since the repository whose conventions apply is what varies between steps. |
tests/test_integration/test_mcp_roots_negotiation.py |
Pins the negotiation rule (real server, no LLM). New file. |
tests/test_providers/test_claude_agent_sdk.py |
settings_dir → add_dirs; server args are not forwarded; independence from cwd; verbatim passthrough; and that a settings_dir is never promoted into cwd. |
tests/test_engine/test_workflow.py |
Resolution, templating, relative paths, and that settings_dir alone leaves working_dir unset. |
tests/test_config/test_settings_dir_schema.py |
Shape and the seven rejections. New file. |
examples/claude-agent-sdk-settings-dir.yaml |
The reviewer-reading-a-target-repo shape, with the mechanism in the header. New file. |
AGENTS.md, docs/workflow-syntax.md, docs/providers/experimental.md |
The mechanism, the limits table, and a correction: the experimental table still said setting_sources was "pinned empty", which this fork's #4 already changed. |
Design alternatives considered
| Option | Why not |
|---|---|
| One filesystem MCP server per declared root | Works, but N processes and an aggregated list_allowed_directories. Unnecessary: a single root that contains both already permits both, which the table above shows. |
Generic per-agent add_dirs: list |
Maps 1:1 onto the flag, but its name advertises "tool access" while the load-bearing effect here is skill discovery — the exact misreading that produced this bug. |
| Docstring fix only (AC2 fallback) | Leaves the derived forwarding in place, and it is not inert. See the PR description. |
Not done
.claude/rules and hooks still require working_dir, so an agent needing a repository's rules and a wide cwd cannot have both. That is stated in the docs and pinned by a test rather than worked around; the caller-side resolution is tracked on the ticket.
🤖 Generated with Claude Code
…stem grant Two independent reviews of the preceding commits, one of which saw only the diff. Four issues, all confirmed by execution before fixing. 1. No capability carve-out. `capabilities.py` states the rule in its own words for the sibling field -- "silently ignoring the directory would run the agent in the wrong repository" -- and `working_dir` and `session_key` both have validator checks. `settings_dir` had none, so it validated green on `copilot` and was discarded. Adds `ProviderCapabilities.settings_dir` (True only on claude-agent-sdk, the only provider with an `add_dirs`) and the matching error. 2. Silent no-op without `setting_sources`. With no tier enabled the skills half does nothing, and validate said nothing. Now a warning, not an error, because the filesystem half still applies -- see 3. 3. The docs omitted the effect that matters most. `add_dirs`' own SDK contract is "additional directories Claude can access beyond the current working directory", so a `settings_dir` widens the model's built-in Read/Edit/Bash to that tree -- and unconditionally, with no settings tier at all. The docs table answered "no" to four things and never mentioned filesystem access. Measured with `setting_sources` unset and `permission_mode: default`: the same read outside cwd is refused for permissions without `settings_dir` and succeeds with it. It still does not widen what an MCP server permits. 4. `AGENTS.md` carried a ticket key -- the first in that file, where all 82 other provenance markers are upstream issue refs. Dropped; the mechanism sentence already carried the content. Also: `settings_dir` escaped `_collect_template_strings`, so a typo'd upstream step name failed at run time where the same typo in `working_dir` fails at validate. The three call-site comments named only `working_dir` despite resolving both. And `test_settings_dir_does_not_become_a_second_cwd` asserted a substring that also passes for an unrelated cwd -- replaced with the assertion its docstring claimed. Tests: the three validator behaviours are covered against the real shipped descriptors, not a patched harness. Every case was green before this commit, which is why they exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review findings addressed (
|
native Read outside cwd |
|
|---|---|
without settings_dir |
"Claude requested permissions to read … but you haven't granted it yet" |
with settings_dir |
succeeds |
Now the first row of the docs table, with a callout, plus the schema docstring and AGENTS.md. It still does not widen what an MCP server permits.
4. Ticket key in AGENTS.md (blocking). The first in that file — all 82 other provenance markers are upstream issue refs, and a fork-local key is unresolvable to an upstream reader. Dropped.
5. Templates escaped reference validation. settings_dir was missing from _collect_template_strings. Verified: {{ nonexistent_step.output.x }} → settings_dir exit 0, working_dir exit 1. Now caught, using getattr to match the convention two lines below (duck-typed fixtures predate the field — direct access broke a pre-existing test, which is how I found that out).
6. A test that read as a guard and wasn't. test_settings_dir_does_not_become_a_second_cwd asserted str(narrow) not in cwd, which also passes for an implementation setting cwd to an unrelated third directory. Replaced with the assertion its docstring claimed. Both reviewers flagged this independently.
7. Stale call-site comments. All three sites said "Resolve working_dir" while resolving both fields. Fixed. I also renamed _resolve_agent_working_dir → _resolve_agent_directories and reverted it: four other modules reference it by name in comments, so the churn cost more than it explained. A docstring note now points there instead.
Not fixed, with reasons
- The integration test imports no conductor code and cannot fail on a conductor regression. Correct, and intended: it pins an external precedence rule that every conclusion here rests on.
TestSettingsDirAddDirspins conductor's own behaviour. Renaming the file to say "probe" rather than "regression" would be fair. - The test is non-hermetic —
npx -yhits the network on a cold cache (measured 8.9s cold vs 2.8s warm) and the server is version-unpinned. Real. A pin plus an opt-in marker is the right fix; I've left it as its own change rather than widening this PR. - The race guard degrades silently. If the server's log wording changes, the 30s wait becomes a no-op and CI just gets slower. The blind reviewer proved this by setting the marker to a string that never appears — all three tests still passed, in 62s. Asserting the marker appeared would fail loudly. Worth doing; not done here.
- The CLI half of the central claim is untested in-repo. Both reviewers noted that "the CLI advertises exactly one root, its cwd" is asserted in several places with no artifact a reader can check — the server half is verified, the CLI half rests on my manual runs. That's a fair criticism of the evidence's reproducibility and I have no cheap fix: it needs a live
claudeinvocation, which the suite deliberately avoids. settings_diris absent fromagent_startedevent payloads (whereworking_dirappears), so it is invisible in the dashboard and JSONL log. Genuine gap for a field that changes which repository's conventions were loaded. Follow-up.- No parallel/for-each/sub-workflow test, though
working_dirhas five. I verified by execution that all three paths resolve it (three call sites, one shared method), so this is coverage asymmetry rather than a bug — but "shared code cannot drift" is exactly the reasoning a future edit invalidates silently.
Verification
uv run pytest tests/test_config tests/test_engine tests/test_providers tests/test_skills tests/test_plugins tests/test_integration/test_mcp_roots_negotiation.py → 4318 passed, 2 skipped, 3 failed. The three are pre-existing on clean main (two need a genuinely unreadable directory, one a case-sensitive filesystem). ruff clean; ty unchanged at 6 diagnostics; make validate-examples passes.
Neither reviewer could falsify the empirical claims; the full-context one independently confirmed the zero-roots SDK result, the []-not-None type choice, and that checkpoint/resume correctly needs no persistence (checkpoints store no AgentDefs).
🤖 Generated with Claude Code
…sured The docs table listed `.claude/settings.json` (`env`, `hooks`) as a single "no" row. The `env` half is measured -- an env var declared there reads back empty. The `hooks` half is not: the probe grepped the CLI's debug output for a hook's marker and found nothing, but also found nothing in the control, where cwd was the directory and the hook should have run. A probe that returns nothing in its own baseline cannot support a negative. Splits the row and says so. Hooks are now documented as undetermined -- do not rely on them running, do not rely on them being suppressed -- with the instrument that would settle it named (a hook with an observable side effect, such as writing a file). The row was correct in direction and unearned in confidence, which is the version that survives review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On hold — proposed upstream insteadThis repository is a fork of microsoft/conductor, and the defect this addresses is in upstream code rather than anything we added. Rather than carry a permanent fork-local patch, the fix has been raised upstream:
This PR stays open and unmerged until that is resolved.
The upstream version is narrower than this branchSame 14 files, but +1196/-34 upstream against +1203/-56 here. The difference is one thing, and it is the reconciliation detail that matters: This branch removes Consequences if we reconcile:
Two claims from this ticket were settled while preparing the upstream versionBoth were flagged on PDA-95 as open, and a third-party PR was not the place to leave them open. Hooks are now a measured negative, not undetermined. My comment of 7 Sep recorded the hooks row as unearned, because the probe grepped "The CLI advertises exactly one root, its cwd" now has direct evidence. Previously it rested on manual runs with no in-repo artifact. Measured against CLI 2.1.263 with an MCP server that logs the client handshake, driven through the provider: Still standing, unchangedThe gaps I listed on the ticket remain, and are named in the upstream PR rather than hidden:
Also unchanged: this does not unblock PDA-79. That correction stands exactly as recorded on the ticket and in this PR's description — PDA-79's blocked ACs need an artifacts-placement decision in pandora and nothing from conductor. Verification of the upstream branchAgainst
🤖 Generated with Claude Code |
Splits the two unrelated jobs
working_dirwas doing on theclaude-agent-sdkprovider, and removes a mitigation that never worked. Fixes the conductor half of PDA-95.The
claudeCLI negotiates MCP Roots and advertises exactly one root — its cwd — so@modelcontextprotocol/server-filesystemdiscards the directories in its own argv and permits cwd alone. cwd is simultaneously what theprojectsettings tier resolves against. Pointingworking_dirat 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; widening it back lost the repository's skills._stdio_path_argsexisted to prevent exactly this, forwarding every stdio server's directory args intoadd_dirswith a docstring asserting it "restores the declared scope". It cannot:--add-dirtakes no part in Roots negotiation. It is removed rather than documented as ineffective, because it also had a live cost — deriving skill discovery from server arguments grants conventions from directories the author named as data.New per-agent
settings_dir, now the only source ofadd_dirs:14 files, +1194 −56. Three commits: the field; a docs/test correction after further measurement; then the fixes from two independent reviews (one of which saw only the diff).
Worth scrutinising
1.
settings_dircarries skills only — it is not a general "load a project tier from anywhere". Measured against the real CLI, with a fixture whose.claude/rulesis a symlink to.agents/rulesand aCLAUDE.mdthat never mentions it:CLAUDE.mdadd_dirs=[repo]So this recovers one third of what #4 (PDA-16) delivers, and the two demands genuinely conflict — one directory cannot be both the repo and a wide MCP root. That limit is now in the docs as a table and pinned by a test; the second commit exists because my first pass tested skills only and documented the rest optimistically.
2. The regression test runs the real MCP server with no LLM.
tests/test_integration/test_mcp_roots_negotiation.pypins the negotiation rule, not our wiring: two runs, byte-identical but for whether the client advertises therootscapability, flipping the allowlist from both declared roots to cwd alone. Everything here rests on that precedence, so it is worth a look — including the deliberate wait for the server's own "Updated allowed directories" line, without which the probe races the swap and reports the opposite result. That false negative is how I first mis-measured this.3.
settings_diris a filesystem grant, and it is unconditional.add_dirs' own SDK contract is "additional directories Claude can access beyond the current working directory", so naming a directory here widens the model's built-inRead/Edit/Bashto that tree whether or not any settings tier is enabled — the skill discovery it is normally set for is the conditional half. Measured withsetting_sourcesunset andpermission_mode: default: the same read outside cwd is refused for permissions withoutsettings_dirand succeeds with it. It does not widen what a filesystem MCP server permits. This is the security-relevant line in the diff, and the docs table originally omitted it entirely — a blind review caught that.First objection a reviewer will raise
Why a new field instead of just fixing the docstring? Because AC2's fallback branch ("document it as ineffective") would leave the derived
add_dirsin place, and it is not inert — it silently widens skill discovery to every declared MCP root. Removing it needs no upstream coordination:_stdio_path_argsis fork-local, added in 4a81064.grep add_dir tests/previously returned nothing, which remains the most direct explanation for how an ineffective mitigation shipped and stayed.Verification
uv run pytest tests/test_config tests/test_engine tests/test_providers tests/test_skills tests/test_plugins tests/test_integration/test_mcp_roots_negotiation.py→ 4318 passed, 2 skipped, 3 failed. The three are pre-existing on cleanmain(two need a genuinely unreadable directory, one a case-sensitive filesystem) — confirmed by stashing and re-running onmain.ruffclean;tyreports the same 6 diagnostics asmain;make validate-examplespasses including the new example.What this does not fix
Judge this on its own merits, not as unblocking anything downstream. It was
motivated by a caller whose steps are denied access to a sibling artifacts
directory, and it does not fix that: those steps sit in two sibling trees,
neither containing the other, so widening one step's cwd cannot bring the
other's paths into scope. That caller needs the artifact path to sit inside
whatever cwd each reader already has — a placement decision in the caller's
repo, needing nothing from conductor. Shared symptom, different problem.
What is on offer here is narrower and stands alone: the mechanism documented
where it was previously asserted backwards, an ineffective mitigation removed,
a regression test for the negotiation rule everything rests on, and
settings_dirfor the case where a step's repo-side need genuinely is skillsalone.
🤖 Generated with Claude Code