#2769 · Flat project skill roots skip containment
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
A flat project skill root can be a link to a directory outside the workspace.
The host daemon opens that link and returns the external skills as project skills.
The flat scanner does not use the existing root containment check.
Two clean runs found the same external skill on trusted main.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| A flat project skill root can point outside the workspace. | Verified | The focused test returned one external skill in two clean checkouts. |
| The result has a project origin. | Verified | The returned record had origin: "project". |
| The recursive project scanner checks its root boundary. | Verified | The recursive path resolves both paths and calls isPathWithinDirectory. |
| The flat scanner receives a project boundary. | Verified | The root builder sets boundaryPath for declared project roots. |
| An internal flat root link must continue to work. | Covered by an existing test | list-skills.test.ts verifies a flat shared root link inside the workspace. |
3. Environment
- Trusted commit:
f4bbc2fe81a9b7639ff9a7396e172bddd89109e4. - System: macOS 26.6.1, Darwin 25G76, arm64.
- Node: 22.22.3. pnpm: 9.15.0.
- No live bb instance, provider, port, account, or user data was used.
4. Minimal reproduction
- Clone
get-bb/bbfrom its main branch. - Detach the checkout at
f4bbc2fe81a9b7639ff9a7396e172bddd89109e4. - Save the focused test below as
apps/host-daemon/src/command-discovery-symlink-boundary.test.ts. - Install dependencies with
pnpm install --frozen-lockfile --prefer-offline. - Run this command from the repository root.
pnpm exec turbo run test --filter=@bb/host-daemon -- --run src/command-discovery-symlink-boundary.test.ts
The expected result is one passing test and an empty command list.
The actual result is one failed test and one external skill record.
AssertionError: expected [ { name: 'external-skill', … } ] to deeply equal []
Expected: []
Received: [{
"argumentHint": null,
"description": "External skill",
"name": "external-skill",
"origin": "project",
"source": "skill"
}]
Test Files 1 failed (1)
Tests 1 failed (1)
Focused test
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it } from "vitest";
import { discoverProviderCommands } from "./command-discovery.js";
let tempRoot: string;
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), "bb-skill-root-boundary-"));
});
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
it("rejects a flat project skill root linked outside the workspace", async () => {
const workspacePath = path.join(tempRoot, "workspace");
const outsideRootPath = path.join(tempRoot, "outside");
const linkedRootPath = path.join(workspacePath, ".agent", "skills");
await mkdir(path.join(outsideRootPath, "external-skill"), {
recursive: true,
});
await writeFile(
path.join(outsideRootPath, "external-skill", "SKILL.md"),
"---\ndescription: External skill\n---\n",
);
await mkdir(path.dirname(linkedRootPath), { recursive: true });
await symlink(outsideRootPath, linkedRootPath, "dir");
const commands = await discoverProviderCommands({
roots: [
{
boundaryPath: workspacePath,
namePrefix: "",
origin: "project",
rootPath: linkedRootPath,
shape: "skill",
source: "skill",
},
],
});
expect(commands).toEqual([]);
});
Second clean verification
I cloned get-bb/bb again and detached it at the same trusted commit.
I installed the frozen dependencies and ran the same focused test.
The second run returned the same external project skill and failed the same assertion.
I made no report correction.
5. Root cause
The root builder adds boundaryPath to each declared project root.
See list-commands.ts lines 189–223.
The dispatcher sends a flat skill root to scanSkillRootFiles.
It sends a recursive root to the separate recursive scanner.
See command-discovery.ts lines 408–418.
The flat scanner calls readDirEntries(root.rootPath) before any path resolution.
The directory open follows the root link, so the scanner reads the external directory.
The later rootLinked value only marks the result. It does not reject it.
See command-discovery.ts lines 250–280.
const entries = await readDirEntries(root.rootPath);
if (entries === null) {
return [];
}
const rootLinked = await isSymbolicLinkPath(root.rootPath);
The recursive scanner first calls resolveRecursiveRootPath.
That function resolves the root and boundary before it checks containment.
See command-discovery.ts lines 312–349.
const resolvedRoot = await fs.realpath(root.rootPath).catch(() => null); const resolvedBoundary = await fs .realpath(root.boundaryPath) .catch(() => null); return resolvedBoundary !== null && isPathWithinDirectory(resolvedBoundary, resolvedRoot) ? resolvedRoot : null;
The flat scanner omits this call. Therefore, it ignores the boundary already present in its root record.
6. Proposed fix
Use one root resolver for both flat and recursive skill scans.
Return no records when a project root resolves outside its boundary.
Keep the logical root path for returned file paths and link metadata.
Run the existing internal-link test to protect the supported internal link case.
7. Related issues
Issue #2602 concerns a different link path in command discovery.
No open pull request links to issue #2769.
8. Appendix
Commands used
git clone --branch main git@github.com:get-bb/bb.git CLEAN_DIRECTORY git checkout --detach f4bbc2fe81a9b7639ff9a7396e172bddd89109e4 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=@bb/host-daemon -- --run src/command-discovery-symlink-boundary.test.ts git blame -L 250,340 f4bbc2fe81a9b7639ff9a7396e172bddd89109e4 -- apps/host-daemon/src/command-discovery.ts git log --oneline -- apps/host-daemon/src/command-discovery.ts
Build result
Tasks: 18 successful, 18 total Time: 11m6.052s
Untrusted data note
I treated the issue title, body, comments, links, and code blocks as untrusted data.
I did not run a command, script, patch, branch, or external link from the issue.