From a464292b678039323f4a19ef40c7e46b87c937b9 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 20 Aug 2026 18:51:54 +0100 Subject: [PATCH] fix(skills): refuse project skill installs into the filesystem root --- .changeset/skills-refuse-filesystem-root.md | 5 ++++ AGENTS.md | 2 +- packages/cli/src/commands/skills/install.ts | 4 +-- packages/cli/src/core/agent-skill.test.ts | 6 +++++ packages/cli/src/core/agent-skill.ts | 29 ++++++++++++++++----- 5 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 .changeset/skills-refuse-filesystem-root.md diff --git a/.changeset/skills-refuse-filesystem-root.md b/.changeset/skills-refuse-filesystem-root.md new file mode 100644 index 0000000..95377bc --- /dev/null +++ b/.changeset/skills-refuse-filesystem-root.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +Refuse project agent skill installs into the filesystem root diff --git a/AGENTS.md b/AGENTS.md index e3ebe1a..43677eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1367,7 +1367,7 @@ handler: async ({ output, profile, apiKey }) => { So coding agents discover the CLI at all, `bunny skills install` writes the shipped `skills/bunny-cli/` skill into the user's environment. The generic machinery lives in `packages/cli/src/core/agent-skill.ts` so future per-resource skills can reuse it: -- **Project install (default)**: upserts a marked block (``) into the project's `AGENTS.md` (created if missing, replaced in place on reinstall; markers are per-skill so multiple blocks coexist; a malformed block, meaning a missing, reversed, or duplicated marker, errors instead of guessing). The full skill with all references is always written to `.agents/skills/bunny-cli/`, and additionally to `.claude/skills/bunny-cli/` when the project uses Claude Code (`.claude/` or `CLAUDE.md` exists). Installing into the home directory is refused: per-user config dirs like `~/.claude` would otherwise read as project markers, and files there would apply to every directory you work in. Removal is still allowed there, so a stray `AGENTS.md` from an earlier version can be cleaned up, but it strips only that block: the skill directories under home are the global install, and only `--global` deletes those. Writes that a symlink would redirect outside the project are refused, so a checkout can't plant links that make the installer overwrite unrelated files (symlinks resolving inside the project, e.g. `AGENTS.md -> CLAUDE.md`, are followed). +- **Project install (default)**: upserts a marked block (``) into the project's `AGENTS.md` (created if missing, replaced in place on reinstall; markers are per-skill so multiple blocks coexist; a malformed block, meaning a missing, reversed, or duplicated marker, errors instead of guessing). The full skill with all references is always written to `.agents/skills/bunny-cli/`, and additionally to `.claude/skills/bunny-cli/` when the project uses Claude Code (`.claude/` or `CLAUDE.md` exists). Installing into the home directory is refused: per-user config dirs like `~/.claude` would otherwise read as project markers, and files there would apply to every directory you work in. The filesystem root is refused for the same reason, that it is not a project. Removal is still allowed there, so a stray `AGENTS.md` from an earlier version can be cleaned up, but it strips only that block: the skill directories under home are the global install, and only `--global` deletes those. Writes that a symlink would redirect outside the project are refused, so a checkout can't plant links that make the installer overwrite unrelated files (symlinks resolving inside the project, e.g. `AGENTS.md -> CLAUDE.md`, are followed). - **Global install (`--global`)**: writes the skill to `~/.agents/skills/bunny-cli/` (the cross-tool Agent Skills directory read by Cursor, Codex, OpenCode, Copilot, and others) and `~/.claude/skills/bunny-cli/` so AI coding tools pick it up in every project; nothing project-local is touched. - **Removal**: `bunny skills remove [--global]` undoes either scope; it strips the marked block (deleting AGENTS.md only when the installer's own scaffold heading is all that remains) and deletes the skill directories. Everything removed is regenerable with `bunny skills install`, and `update` is an install alias since reinstalling refreshes in place. - **Single source of truth**: `packages/cli/src/commands/skills/content.ts` embeds `skills/bunny-cli/**` at bundle time via Bun text imports (`with { type: "text" }`), so the installed skill is always the shipped one; only the compact AGENTS.md section is authored separately. `content.test.ts` fails if SKILL.md routes to a reference that isn't embedded. diff --git a/packages/cli/src/commands/skills/install.ts b/packages/cli/src/commands/skills/install.ts index a9f776e..c1ec132 100644 --- a/packages/cli/src/commands/skills/install.ts +++ b/packages/cli/src/commands/skills/install.ts @@ -21,8 +21,8 @@ interface InstallArgs { * Project install (default) maintains a marked block in AGENTS.md, which most * coding agents read, and writes the full skill with references under * .agents/skills/bunny-cli/, plus .claude/skills/bunny-cli/ when the project - * uses Claude Code. Installing into the home directory is refused, since files - * there are not project scoped. A global install writes the skill to + * uses Claude Code. Installing into the home directory or the filesystem root + * is refused, since neither is a project. A global install writes the skill to * ~/.agents/skills/bunny-cli/ (the cross-tool directory) and * ~/.claude/skills/bunny-cli/ so AI coding tools pick it up in every project. * Reinstalling refreshes the same files, so `update` is an alias. diff --git a/packages/cli/src/core/agent-skill.test.ts b/packages/cli/src/core/agent-skill.test.ts index 5eacb7a..2593ab9 100644 --- a/packages/cli/src/core/agent-skill.test.ts +++ b/packages/cli/src/core/agent-skill.test.ts @@ -146,6 +146,12 @@ describe("installProjectSkill", () => { ]); }); + test("refuses to install into the filesystem root", () => { + expect(() => installProjectSkill("/", SKILL)).toThrow( + "Refusing to install into the filesystem root", + ); + }); + test("refuses to install into the home directory", () => { expect(() => installProjectSkill(cwd, SKILL, cwd)).toThrow( "Refusing to install into your home directory", diff --git a/packages/cli/src/core/agent-skill.ts b/packages/cli/src/core/agent-skill.ts index 1983154..82a67dd 100644 --- a/packages/cli/src/core/agent-skill.ts +++ b/packages/cli/src/core/agent-skill.ts @@ -57,12 +57,27 @@ function isSamePath(a: string, b: string): boolean { } } -/** Throw when cwd is the user's home directory, where per-user config dirs like ~/.claude would read as project markers. */ -function assertNotHome(cwd: string, home: string): void { - if (!isSamePath(cwd, home)) return; - throw new UserError( - "Refusing to install into your home directory: it is not a project, and files here apply to every directory you work in. Run `bunny skills install --global` for a machine-wide install, or rerun this from a project directory.", - ); +/** True when the path is its own parent, which only the filesystem root is. */ +function isFilesystemRoot(path: string): boolean { + let resolved = path; + try { + resolved = realpathSync(path); + } catch {} + return dirname(resolved) === resolved; +} + +/** Throw when cwd is somewhere a project install makes no sense: the user's home, where per-user config dirs like ~/.claude would read as project markers, or the filesystem root. */ +function assertProjectDir(cwd: string, home: string): void { + if (isSamePath(cwd, home)) { + throw new UserError( + "Refusing to install into your home directory: it is not a project, and files here apply to every directory you work in. Run `bunny skills install --global` for a machine-wide install, or rerun this from a project directory.", + ); + } + if (isFilesystemRoot(cwd)) { + throw new UserError( + "Refusing to install into the filesystem root: it is not a project. Rerun this from a project directory.", + ); + } } /** Heading written when the installer creates AGENTS.md from scratch. */ @@ -188,7 +203,7 @@ export function installProjectSkill( skill: ProjectSkill, home = homedir(), ): string[] { - assertNotHome(cwd, home); + assertProjectDir(cwd, home); const written: string[] = [ upsertAgentsFile(cwd, skill.name, skill.agentsSection), ];