Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
| `<project>/.claude/skills`, `agents` | **Yes — baseline leak** | Keep the dir empty, or move those skills into a project-scope bundle (see below). |
| `<project>/.mcp.json` | No, when bundle defines MCPs| Bundle adds `--strict-mcp-config`. Set [`mergeMcp: true`](docs/bundles-spec.md) to additive-merge. |
| `<project>/.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
<cache>`, 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. |
| `<project>/.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). |
| `<project>/.mcp.json` | No, when bundle defines MCPs | Bundle adds `--strict-mcp-config`. Set [`mergeMcp: true`](docs/bundles-spec.md) to additive-merge. |
| `<project>/.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 <cache> …`, 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).
Expand Down
8 changes: 7 additions & 1 deletion docs/bundles-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ in DuckDB / pandas.
| `hooks` | no | list of qualified refs | Each entry is `<source>/<leaf>`. Resolved against `~/.config/umbel/hooks/<source>/<leaf>/HOOK.md` + sidecars. |
| `mcps` | no | list of qualified refs | Each entry is `<source>/<leaf>`. Resolved against `~/.config/umbel/mcps/<source>/<leaf>/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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion src/bundle/claude-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
1 change: 1 addition & 0 deletions src/bundle/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ function resolvedFrontmatter(bundle: ResolvedBundle, hash: string): Record<strin
if (v !== undefined && v.length > 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;
}
Expand Down
1 change: 1 addition & 0 deletions src/bundle/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
15 changes: 15 additions & 0 deletions src/bundle/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ export interface BundleManifest {
/** List of qualified `<source>/<leaf>` 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;
Expand Down Expand Up @@ -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<string, unknown>;
for (const key of Object.keys(settings)) {
Expand Down Expand Up @@ -144,6 +158,7 @@ const KNOWN_FIELDS = new Set([
"hooks",
"mcps",
"mergeMcp",
"isolate",
"settings",
]);

Expand Down
52 changes: 52 additions & 0 deletions test/unit/bundle/claude-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,58 @@ describe("computeClaudeArgs", () => {
});
});

// Opt-in isolation (`isolate: true`).
//
// `umbel run <bundle>` launches `claude --plugin-dir <cache>`. 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"]);
Expand Down
10 changes: 10 additions & 0 deletions test/unit/bundle/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } } }),
Expand Down
15 changes: 15 additions & 0 deletions test/unit/bundle/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading