#3856 · Project skill discovery rejects internal aliases
REPRODUCED · Root-cause confidence: high
1. TL;DR
The project skill scanner omits directory aliases even when their resolved target is inside the supplied workspace boundary. A fresh filesystem fixture reproduces this on trusted main; the same content is discovered through a real directory and a user-origin alias. The eligibility predicate permits only user-origin skill symlinks and never evaluates the project boundary. This report verifies the scanner, not a live provider session or slash picker.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Internal project skill aliases disappear | Verified | Two clean runs return zero project records; direct and user controls each return one. |
| Claude Code roots use this scanner | Verified statically | Native root declaration and directoryScanRoot map to the flat skill shape. |
| Pi always loses the shared catalog | Conditional | Pi also declares .agents/skills; real entries there remain discoverable. A catalog consisting of links is affected. |
| Reported live counts and native-provider startup results | Unverified | No real provider or user workspace accessed. |
3. Environment
macOS / Darwin arm64, Node v22.22.3, repository bb 0.43.1, pnpm 9.15.0. Both detached checkouts used the exact base above. No application server, provider session, ports, database, or persistent user data was used. Each test creates and removes its own temporary fixture. The installed pnpm launcher was broken; the repository-pinned version was invoked through npx with npm_config_manage_package_manager_versions=false. Frozen installs succeeded in both checkouts; the full base build passed 58/58 Turbo tasks.
4. Minimal reproduction
- Clone get-bb/bb and detach at
e9de27c514acf8a0ba715218513bf433da06e357. - Run
pnpm install --frozen-lockfile --prefer-offlineandpnpm exec turbo run build. - Save this authored test to
apps/host-daemon/src/issue-3856.test.ts. - Run
pnpm exec turbo run test --filter=@bb/host-daemon -- issue-3856.test.ts.
The test checks the resolved target, then runs the same scanner with direct-project, linked-user, and linked-project roots.
Expected project names: ["sample"]
Observed controls and project result:
{"direct":["sample"],"user":["sample"],"project":[]}
AssertionError: expected [] to deeply equal [ 'sample' ]
Test Files 1 failed (1)
Tests 1 failed (1)import { mkdtemp, mkdir, writeFile, symlink, rm, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, it } from "vitest";
import { discoverProviderCommands, type CommandScanRoot } from "./command-discovery.js";
it("discovers an internal project directory alias with real and user controls", async () => {
const workspace = await mkdtemp(path.join(tmpdir(), "skill-discovery-"));
try {
const catalog = path.join(workspace, "catalog");
const provider = path.join(workspace, "provider");
await mkdir(path.join(catalog, "sample"), { recursive: true });
await mkdir(provider);
await writeFile(path.join(catalog, "sample", "SKILL.md"), "# Sample\n");
await symlink(path.join(catalog, "sample"), path.join(provider, "sample"), "dir");
expect(await realpath(path.join(provider, "sample"))).toBe(path.join(await realpath(workspace), "catalog", "sample"));
const scan = (rootPath: string, origin: "project" | "user") => {
const root: CommandScanRoot = { rootPath, origin, shape: "skill", source: "skill", namePrefix: "", boundaryPath: workspace };
return discoverProviderCommands({ roots: [root] });
};
const direct = await scan(catalog, "project");
const user = await scan(provider, "user");
const project = await scan(provider, "project");
console.log(JSON.stringify({ direct: direct.map(x => x.name), user: user.map(x => x.name), project: project.map(x => x.name) }));
expect(direct.map(x => x.name)).toEqual(["sample"]);
expect(user.map(x => x.name)).toEqual(["sample"]);
expect(project.map(x => x.name)).toEqual(["sample"]);
} finally {
await rm(workspace, { recursive: true, force: true });
}
});
5. Root cause
canFollowSkillSymlink and directory gating reject a symbolic entry unless its root has user origin. The flat scanner skips that entry before reading SKILL.md. Linked SKILL.md files have the same gate, although this reproduction targets directory aliases only.
return root.origin === "user" && root.source === "skill";
scanSkillRootFiles delegates to that gate. Root construction already supplies boundaryPath, but the eligibility predicate does not consult it. Claude Code declares an ancestor-scanned project root; Pi declares two project roots. The recursive scanner's boundary resolution does not authorize entries in this separate flat path.
6. Proposed fix
Design a boundary-aware project-link policy that resolves the target and owning workspace or repository boundary before permitting reads. Cover directory links, linked skill files, multi-hop links, broken links, external targets, and missing boundaries. Preserve user-origin behavior and rejection of paths outside the project boundary. No production patch was made: this changes filesystem traversal at a security boundary, which excludes it from the caller's automatic simple-fix process regardless of line count.
7. PR review
PR #3857 is closed. Its metadata links this issue; no matching open PR was found. Static diff review only: it replaces the user-only check with realpath containment for project entries and updates tests/documentation. That approach addresses the verified gate. No PR code or test was executed. Before acceptance, expand coverage for boundary absence and multi-hop/file escapes; do not infer safety from one positive fixture. Verdict: plausible direction, runtime and security behavior unverified.
8. Related issues
Metadata search found #3238 (another skill scanner), #2602 (command discovery), and #2769 (root traversal outside a repository). These explain related scope but were not reproduced here.
9. Verification
The same agent repeated the authored test in a second clean detached checkout named verify, with a separate frozen install and a new mkdtemp fixture. Both checkouts were pinned to e9de27c514acf8a0ba715218513bf433da06e357; only the test was added. The exact test command was pnpm exec turbo run test --filter=@bb/host-daemon -- issue-3856.test.ts. Both runs executed the test (no cached failing result) and failed the project assertion at line 27 after both controls passed. No corrections to the scanner finding were needed. This is a repeat verification by the same agent, not an independent review.
10. Appendix
First run · Second run · Full build · Existing discovery suite. Machine-specific paths are normalized in logs. Build/test invocations used npm_config_manage_package_manager_versions=false npx --yes pnpm@9.15.0 in place of the broken pnpm launcher. Read-only investigation used git fetch/rev-parse/show, GitHub issue/property/label queries, PR metadata/diff reads, and source reads. Issue and PR contents were treated as untrusted claims; their commands and tests were not executed. No dependencies, production files, or public contracts were changed.