diff --git a/README.md b/README.md index b8f8bd8..83cd295 100644 --- a/README.md +++ b/README.md @@ -128,20 +128,38 @@ See [`docs/bundles-spec.md`](docs/bundles-spec.md) for the full design. ## What's isolated, what leaks -A bundle session does **not** see your everyday Claude Code skills, agents, or -plugins from `~/.claude/`. Some surfaces still pass through, by design or by -quirk: - -| Surface | Visible under a bundle? | How to control it | -|-----------------------------------------------|-----------------------------|-----------------------------------------------------------------------------| -| `~/.claude/skills`, `agents`, `plugins` | **No** — fully isolated | Nothing to do; `--plugin-dir` + `--settings` replace user-scope sources. | -| `~/.claude/` oauth, history, projects state | Yes (shared on purpose) | Keeps you logged in across bundles. Bundles never write here. | -| `/.claude/skills`, `agents` | **Yes — baseline leak** | Keep the dir empty, or move those skills into a project-scope bundle (see below). | -| `/.mcp.json` | No, when bundle defines MCPs| Bundle adds `--strict-mcp-config`. Set [`mergeMcp: true`](docs/bundles-spec.md) to additive-merge. | -| `/.mcp.json` | Yes, when bundle has no MCPs| The strict flag is only emitted alongside bundle MCPs. | +By default a bundle is **additive**: `umbel run` launches `claude --plugin-dir +`, and in Claude Code `--plugin-dir` only *adds* the bundle on top of +your normal discovery. So your globally-enabled plugins (`enabledPlugins` in +`~/.claude/settings.json`) and `~/.claude/skills` still show up in the session. +Set [`isolate: true`](docs/bundles-spec.md) on the bundle to suppress them. + +| Surface | Visible under a bundle? | How to control it | +|-----------------------------------------------|-----------------------------------|-----------------------------------------------------------------------------| +| `~/.claude/skills`, `agents`, enabled `plugins` | **Yes, by default — leaks** | `--plugin-dir` only adds the bundle; it does not replace user-scope discovery. Set [`isolate: true`](docs/bundles-spec.md) to launch with `--bare` and load **only** the bundle. | +| `~/.claude/` oauth, history, projects state | Yes (shared on purpose) | Keeps you logged in across bundles. Bundles never write here. `isolate` does not touch this. | +| `/.claude/skills`, `agents` | **Yes — baseline leak** | `isolate: true` (`--bare`) drops these too; otherwise keep the dir empty, or move those skills into a project-scope bundle (see below). | +| `/.mcp.json` | No, when bundle defines MCPs | Bundle adds `--strict-mcp-config`. Set [`mergeMcp: true`](docs/bundles-spec.md) to additive-merge. | +| `/.mcp.json` | Yes, when bundle has no MCPs | The strict flag is only emitted alongside bundle MCPs. | So if your goal is "don't show me anything other than what this bundle -declares", you have to: +declares", set `isolate: true`: + +```yaml +--- +name: thinking +isolate: true +skills: [local/brainstorming, local/tdd] +--- +``` + +This launches `claude --bare --plugin-dir …`, so Claude Code loads +**only** the bundle's skills/agents — no user plugins, no `~/.claude/skills`, +no project-scope auto-discovery. The bundle's own `--settings` / `--mcp-config` +still apply, and shared state (oauth, history) is untouched. + +Without `isolate` the bundle stays purely additive (the default). To narrow an +additive session by hand instead: 1. Author the bundle (user-scope or project-scope — bundles themselves are not the leak). diff --git a/docs/bundles-spec.md b/docs/bundles-spec.md index 7bbde27..fff5598 100644 --- a/docs/bundles-spec.md +++ b/docs/bundles-spec.md @@ -83,6 +83,7 @@ in DuckDB / pandas. | `hooks` | no | list of qualified refs | Each entry is `/`. Resolved against `~/.config/umbel/hooks///HOOK.md` + sidecars. | | `mcps` | no | list of qualified refs | Each entry is `/`. Resolved against `~/.config/umbel/mcps///MCP.md` + sidecars. | | `mergeMcp` | no | bool, default false | When true, omit `--strict-mcp-config`; bundle MCPs add to project. | +| `isolate` | no | bool, default false | When true, launch adds `--bare` so the session loads **only** the bundle's artifacts — user plugins, `~/.claude/skills`, and project-scope auto-discovery are skipped. Default keeps the additive behaviour where `--plugin-dir` layers the bundle on top of normal discovery. | | `settings` | no | object | Whitelisted keys only (see [Settings whitelist](#settings-whitelist)). | Unknown fields → build warning, ignored. @@ -140,7 +141,7 @@ subfolders). Per-artifact root override is not in scope. - **Lists** (`skills`, `agents`, `hooks`, `mcps`): concat parent's then child's, dedupe by ref with **child winning**. - **Maps** (`settings`): deep-merge with child keys overriding. - - **Scalars** (`description`, `mergeMcp`): child overrides parent. + - **Scalars** (`description`, `mergeMcp`, `isolate`): child overrides parent. 4. `extends:` itself is not inherited (each bundle declares its own parents). Missing parent name → build error. @@ -264,6 +265,11 @@ Rules for the `## Invocation` block: - `--mcp-config` line present only when `.mcp.json` was emitted. - `--strict-mcp-config` line present only when previous AND `mergeMcp` is not `true`. +- `--bare` line present (before `--plugin-dir`) only when `isolate` is `true`. + `--plugin-dir` alone is additive — Claude Code layers the bundle on top of + the user's enabled plugins and `~/.claude/skills`; `--bare` makes it load + only the bundle plugin while still honouring the `--settings` / `--mcp-config` + emitted below. - Future bundle frontmatter fields that map to claude flags add new lines here. This is the **single source of truth** for the bundle-features → claude-flags mapping; consumers do not reimplement it. diff --git a/src/bundle/claude-args.ts b/src/bundle/claude-args.ts index 366be57..27f3dc2 100644 --- a/src/bundle/claude-args.ts +++ b/src/bundle/claude-args.ts @@ -8,7 +8,17 @@ import type { ResolvedBundle } from "./compose.ts"; * cache directory path — no filesystem inspection. */ export function computeClaudeArgs(bundle: ResolvedBundle, cacheDir: string): string[] { - const args: string[] = ["--plugin-dir", cacheDir]; + const args: string[] = []; + + // Opt-in full isolation. `--plugin-dir` only ADDS the bundle on top of the + // user's normal discovery, so globally-enabled plugins and ~/.claude skills + // still leak in. `--bare` makes Claude Code load ONLY the explicitly-passed + // plugin (and its skills/agents) — it skips user plugins, ~/.claude skills, + // and project-scope auto-discovery, while still honouring the bundle's own + // --settings / --mcp-config below. + if (bundle.isolate === true) args.push("--bare"); + + args.push("--plugin-dir", cacheDir); if (hasSettings(bundle)) { args.push("--settings", join(cacheDir, "settings.json")); diff --git a/src/bundle/compile.ts b/src/bundle/compile.ts index 684dd50..b23d8b8 100644 --- a/src/bundle/compile.ts +++ b/src/bundle/compile.ts @@ -362,6 +362,7 @@ function resolvedFrontmatter(bundle: ResolvedBundle, hash: string): Record 0) out[f] = v; } if (bundle.mergeMcp !== undefined) out.mergeMcp = bundle.mergeMcp; + if (bundle.isolate !== undefined) out.isolate = bundle.isolate; if (bundle.settings && Object.keys(bundle.settings).length > 0) { out.settings = bundle.settings; } diff --git a/src/bundle/compose.ts b/src/bundle/compose.ts index 841525a..3cc601c 100644 --- a/src/bundle/compose.ts +++ b/src/bundle/compose.ts @@ -67,6 +67,7 @@ function mergePair(parent: ResolvedBundle, child: BundleManifest): ResolvedBundl if (childPlain.body !== undefined) out.body = childPlain.body; if (childPlain.description !== undefined) out.description = childPlain.description; if (childPlain.mergeMcp !== undefined) out.mergeMcp = childPlain.mergeMcp; + if (childPlain.isolate !== undefined) out.isolate = childPlain.isolate; for (const f of ARTIFACT_KINDS) { const merged = mergeStringList(parent[f], childPlain[f]); diff --git a/src/bundle/manifest.ts b/src/bundle/manifest.ts index 58627b8..24baced 100644 --- a/src/bundle/manifest.ts +++ b/src/bundle/manifest.ts @@ -51,6 +51,14 @@ export interface BundleManifest { /** List of qualified `/` refs. */ mcps?: string[]; mergeMcp?: boolean; + /** + * Opt-in full isolation. When true, the session loads ONLY the bundle's own + * artifacts: the launch adds `--bare`, so Claude Code skips the user's + * globally-enabled plugins, `~/.claude/skills`, and project-scope + * auto-discovery. Default (false/absent) keeps today's additive behaviour — + * `--plugin-dir` layers the bundle on top of whatever is already enabled. + */ + isolate?: boolean; settings?: BundleSettings; body: string; sourcePath: string; @@ -111,6 +119,12 @@ export function loadManifest(path: string): ManifestResult { if (data.mergeMcp !== undefined) { manifest.mergeMcp = data.mergeMcp as boolean; } + if (data.isolate !== undefined) { + if (typeof data.isolate !== "boolean") { + throw new UsageError(`bundle ${path}: 'isolate' must be a boolean`); + } + manifest.isolate = data.isolate; + } if (data.settings !== undefined) { const settings = data.settings as Record; for (const key of Object.keys(settings)) { @@ -144,6 +158,7 @@ const KNOWN_FIELDS = new Set([ "hooks", "mcps", "mergeMcp", + "isolate", "settings", ]); diff --git a/test/unit/bundle/claude-args.test.ts b/test/unit/bundle/claude-args.test.ts index 505e2be..db505e1 100644 --- a/test/unit/bundle/claude-args.test.ts +++ b/test/unit/bundle/claude-args.test.ts @@ -46,6 +46,58 @@ describe("computeClaudeArgs", () => { }); }); +// Opt-in isolation (`isolate: true`). +// +// `umbel run ` launches `claude --plugin-dir `. In Claude Code +// 2.1.x `--plugin-dir` ADDS the bundle plugin on top of normal discovery — it +// does NOT suppress the user's globally-enabled plugins (`enabledPlugins` in +// ~/.claude/settings.json) nor ~/.claude/skills. So those skills leak into a +// bundle session. `isolate: true` opts a bundle into `--bare`, the documented +// lever that loads ONLY the --plugin-dir plugin (and its own skills/agents). +// Isolation is opt-in to preserve today's additive default behaviour. +describe("computeClaudeArgs — opt-in isolation (isolate: true)", () => { + const cache = "/abs/cache"; + + const isolatesUserPlugins = (args: string[]): boolean => { + if (args.includes("--bare")) return true; + const i = args.indexOf("--setting-sources"); + if (i === -1) return false; + const sources = (args[i + 1] ?? "").split(",").map((s) => s.trim()); + return sources.length > 0 && !sources.includes("user"); + }; + + it("does NOT isolate by default — additive behaviour preserved", () => { + const args = computeClaudeArgs(bundle({ skills: ["superpowers/brainstorming"] }), cache); + expect(isolatesUserPlugins(args)).toBe(false); + expect(args).toEqual(["--plugin-dir", cache]); + }); + + it("isolate: true drops the user's globally-enabled plugins", () => { + const args = computeClaudeArgs( + bundle({ skills: ["superpowers/brainstorming"], isolate: true }), + cache, + ); + expect(isolatesUserPlugins(args)).toBe(true); + expect(args).toContain("--plugin-dir"); + expect(args).toContain(cache); + }); + + it("isolate keeps the bundle's own settings flowing (still passes --settings)", () => { + const args = computeClaudeArgs( + bundle({ skills: ["local/tdd"], isolate: true, settings: { model: "claude-opus-4-7" } }), + cache, + ); + expect(isolatesUserPlugins(args)).toBe(true); + expect(args).toContain("--settings"); + expect(args).toContain(`${cache}/settings.json`); + }); + + it("isolate: false behaves like the default (no isolation)", () => { + const args = computeClaudeArgs(bundle({ skills: ["local/tdd"], isolate: false }), cache); + expect(isolatesUserPlugins(args)).toBe(false); + }); +}); + describe("formatClaudeInvocation", () => { it("renders a flag-only argv as backslash-continued bash", () => { const out = formatClaudeInvocation(["--plugin-dir", "/abs"]); diff --git a/test/unit/bundle/compose.test.ts b/test/unit/bundle/compose.test.ts index cad753a..9eabfef 100644 --- a/test/unit/bundle/compose.test.ts +++ b/test/unit/bundle/compose.test.ts @@ -47,6 +47,16 @@ describe("compose", () => { expect(r.mergeMcp).toBe(true); }); + it("inherits isolate from parent; child can override it off", () => { + const ix = index( + m("base", { isolate: true }), + m("child", { extends: ["base"], skills: ["c"] }), + m("opt-out", { extends: ["base"], isolate: false }), + ); + expect(compose("child", ix).isolate).toBe(true); + expect(compose("opt-out", ix).isolate).toBe(false); + }); + it("deep-merges settings env across parent + child", () => { const ix = index( m("base", { settings: { model: "old", env: { A: "1" } } }), diff --git a/test/unit/bundle/manifest.test.ts b/test/unit/bundle/manifest.test.ts index cd7e74e..3a658e4 100644 --- a/test/unit/bundle/manifest.test.ts +++ b/test/unit/bundle/manifest.test.ts @@ -102,6 +102,21 @@ Bundle for ad-hoc analysis. expect(warnings).toEqual([]); }); + it.each([ + ["true", true], + ["false", false], + ])("parses 'isolate: %s' as a known boolean field", (literal, expected) => { + const path = writeBundle("iso", `---\nname: iso\nisolate: ${literal}\n---\n`); + const { manifest, warnings } = loadManifest(path); + expect(manifest.isolate).toBe(expected); + expect(warnings).toEqual([]); + }); + + it("rejects non-boolean 'isolate'", () => { + const path = writeBundle("bad-iso", "---\nname: bad-iso\nisolate: yes-please\n---\n"); + expect(() => loadManifest(path)).toThrow(/isolate.*boolean/i); + }); + it("rejects settings keys outside the whitelist", () => { const path = writeBundle( "bad-settings",