#4164 · Native project skills remain in the injected plugin

Bug · Priority Medium · Effort Medium · providers · provider-claude-code · 2026-09-23
Base 94d77da09568d7f0f7cdb23b84686e743acfb0ad · Issue

PARTIALLY REPRODUCED · Root-cause confidence: high for the project collision

1. TL;DR

BB passes the complete staged skill directory to Claude as a local plugin. In an isolated project fixture, Claude's SDK initialization lists the same file twice: once by its native name and once with the BB plugin prefix. A home-root fixture returned only the native name, despite both filesystem paths existing. Both results repeated in a second clean checkout. The issue's token estimate, description truncation, and settings override claim were not verified.

2. Claims vs findings

ClaimFindingEvidence
Project-native skill is also injectedVerifiedActual SDK supportedCommands() contains both names; symlinks resolve to the same file.
Home-native skill is always duplicatedNot reproduced in this fixtureSDK lists only collision-probe in both runs; this does not disprove other versions or layouts.
Large token overhead and empty descriptionsUnverifiedNo model prompt, token measurement, or large catalog test.
Settings cannot hide the injected copyUnverifiedNo settings override experiment.

3. Environment

Darwin arm64; Node 22.22.3; locked Claude Agent SDK 0.3.245. Two clean detached worktrees at the full SHA above. Frozen installs succeeded through Corepack pnpm 9.15.0 after the default pnpm launcher failed to find its own module. Full base build succeeded (60 tasks); provider test suite passed (363 tests in 26 files). No BB instance, server ports, live account, or model turn was used. Each SDK child receives a temporary HOME/config directory, temporary project, and a dummy invalid API key. Fixture directories are removed after the session closes.

4. Minimal reproduction

Download collision.mjs, then run:

git clone https://github.com/get-bb/bb.git bb-4164
cd bb-4164
git checkout --detach 94d77da09568d7f0f7cdb23b84686e743acfb0ad
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
node --disable-warning=ExperimentalWarning --experimental-strip-types /path/to/collision.mjs "$PWD" --sdk
node --disable-warning=ExperimentalWarning --experimental-strip-types /path/to/collision.mjs "$PWD" --expect-dedup
pnpm exec turbo run test --filter=bb-plugin-provider-claude-code

The script calls the trusted production ensureClaudeSkillPlugin function and the locked SDK. The staged and native skill paths refer to one file. It does not imitate Claude's scanner.

Expected: one available entry for that skill in each scope. Actual SDK initialization output, repeated in both checkouts:

home: native=true injected=true same-file=true
home: SDK names=["collision-probe"]
project: native=true injected=true same-file=true
project: SDK names=["collision-probe","bb-global-skills:collision-probe"]

The separate --expect-dedup mode tests the desired omission at the assembly boundary. It fails (exit 1) on unchanged production code:

AssertionError [ERR_ASSERTION]: Native collision must be omitted from injection (production retains it)
true !== false

This is a diagnostic expectation: the current assembly API receives no native-root context. It is not sufficient by itself to specify safe filtering.

Full reproduction source
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, existsSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const checkout = resolve(process.argv[2]);
const { ensureClaudeSkillPlugin } = await import(pathToFileURL(join(checkout, "plugins/provider-claude-code/src/bridge/skill-plugins.ts")));
const scratch = mkdtempSync(join(tmpdir(), "bb-4164-fixture-"));
try {
  const injectedFiles = [];
  for (const scope of ["home", "project"]) {
    const stage = join(scratch, scope, "staged");
    const native = join(scratch, scope, ".claude", "skills");
    mkdirSync(join(stage, "collision-probe"), { recursive: true });
    mkdirSync(native, { recursive: true });
    writeFileSync(join(stage, "collision-probe", "SKILL.md"), "---\nname: collision-probe\ndescription: Isolated collision probe\n---\n");
    symlinkSync(join(stage, "collision-probe"), join(native, "collision-probe"), "dir");
    const plugin = ensureClaudeSkillPlugin({ pluginsRoot: join(scratch, scope, "plugins"), root: { id: "fixture", path: stage } });
    const injectedFile = join(plugin, "skills", "collision-probe", "SKILL.md");
    injectedFiles.push(injectedFile);
    const nativeFile = join(native, "collision-probe", "SKILL.md");
    assert.equal(existsSync(nativeFile), true);
    assert.equal(realpathSync(injectedFile), realpathSync(nativeFile));
    console.log(`${scope}: native=true injected=${existsSync(injectedFile)} same-file=true`);
    if (process.argv.includes("--sdk")) {
      const { query } = await import(pathToFileURL(join(checkout, "plugins/provider-claude-code/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs")));
      const isolatedHome = scope === "home" ? join(scratch, scope) : join(scratch, scope, "empty-home");
      const cwd = scope === "project" ? join(scratch, scope) : join(scratch, scope, "workspace");
      mkdirSync(isolatedHome, { recursive: true });
      mkdirSync(cwd, { recursive: true });
      let finish;
      const input = new Promise(resolve => { finish = resolve; });
      async function* prompt() { await input; }
      const session = query({ prompt: prompt(), options: {
        cwd, settingSources: ["user", "project", "local"],
        plugins: [{ type: "local", path: plugin }],
        env: { PATH: process.env.PATH, HOME: isolatedHome, CLAUDE_CONFIG_DIR: join(isolatedHome, ".claude"), ANTHROPIC_API_KEY: "isolated-not-a-real-key", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" },
        persistSession: false,
      } });
      const timeout = setTimeout(() => session.close(), 20000);
      try {
        const commands = await session.supportedCommands();
        console.log(`${scope}: SDK names=${JSON.stringify(commands.filter(item => item.name.includes("collision-probe")).map(item => item.name))}`);
      } finally { clearTimeout(timeout); finish(); session.close(); }
    }
  }
  if (process.argv.includes("--expect-dedup")) {
    assert.equal(injectedFiles.some(existsSync), false, "Native collision must be omitted from injection (production retains it)");
  }
} finally {
  rmSync(scratch, { recursive: true, force: true });
}

5. Root cause

ensureClaudeSkillPlugin links the entire staged root as the plugin skills directory. Its arguments contain no session cwd or resolved native visibility. assembleSkillPlugins runs when skills are configured, and session construction reuses those roots. Session parameter mapping passes the local plugin paths through. SDK startup also enables user, project, and local settings sources. The project fixture proves that this combination exposes a native entry and a namespaced entry for one physical skill.

symlinkSync(args.root.path, skillsLink, "dir");
settingSources: ["user", "project", "local"],

6. Proposed fix and safety assessment

Resolve native visibility per session before assembling its injected skill set, preserving BB-only skills and other providers. First establish the SDK's scope-specific identity and precedence rules with tests for user/project roots, configuration overrides, disabled sources, aliases, and equal names with different content. The observed home/project difference means a name-only filesystem filter is not yet justified by the evidence. No automatic fix branch or PR was created: this investigation has not established a safe filtering contract or a focused regression test that passes with a validated fix. No production source was changed.

7. Verification

The same agent repeated the filesystem probe and real SDK initialization from a second clean detached checkout at 94d77da09568d7f0f7cdb23b84686e743acfb0ad, with a fresh frozen install and new temporary fixture roots. Command: node --disable-warning=ExperimentalWarning --experimental-strip-types collision.mjs /path/to/second-checkout --sdk. Exit 0; output matched the first run exactly. The --expect-dedup probe failed with exit 1 again. The report limits the verdict to PARTIALLY REPRODUCED because the home fixture did not duplicate and prompt-budget effects were not measured. This is repeat verification by the same agent, not an independent review.

8. Related issues and pull requests

No open pull request linked to #4164 was found through cross-reference metadata or the open-PR search at investigation time. Repository search also found #3926 and #3925, concerning provider skill-root resolution; neither was reproduced or assessed here.

9. Appendix

Issue content and external links were treated as untrusted claims. No linked patch or fork was fetched or executed. All executable repository code came from trusted origin/main; the fixture was authored from local repository evidence. All temporary fixture sessions were closed and their data removed.