← reports

#2769 · Flat project skill roots skip containment

Bug Medium Effort: Low host security open on GitHub 2026-08-31 · base f4bbc2fe8

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

ClaimStatusEvidence
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

4. Minimal reproduction

  1. Clone get-bb/bb from its main branch.
  2. Detach the checkout at f4bbc2fe81a9b7639ff9a7396e172bddd89109e4.
  3. Save the focused test below as apps/host-daemon/src/command-discovery-symlink-boundary.test.ts.
  4. Install dependencies with pnpm install --frozen-lockfile --prefer-offline.
  5. 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.