We build client websites by running Claude Code (claude -p) in a per-client folder — a workspace. The agent in a workspace should reach the skills that build sites (write a page, edit the CMS, find an image) — call those the operator skills — and none of our internal tooling: the skills that ship our own product, open our pull requests, or edit other skills. A client's build has no business invoking our release command.
We thought one flag handled this. It didn't, and the gap is worth writing down, because the fix isn't where you'd look first.
The flag we leaned on scopes the wrong thing
We launch every workspace turn with --setting-sources project. The name suggests "only this project's config," and that's true — for settings. It restricts which settings.json, hooks, and MCP servers load to the current directory's .claude/, and it blocks your user-level ~/.claude/ entirely. Good. That's why a developer's personal skills never bleed into a client run.
What it does not touch is skill discovery. Those are two different systems. Claude Code finds skills by walking the filesystem: it reads .claude/skills/ in the current directory and in every parent directory up to the repository root. --setting-sources project has no say over that walk. So a skill can be completely undiscoverable by settings policy and still get loaded, simply because it sits in a parent folder.
That distinction — settings sources vs skill discovery — is the whole post. Miss it and you'll believe you're isolated when you're not.
Why that leaks in a multi-tenant layout
Here's the trap. Our client workspaces live under the main repo — roughly repo/workspaces/<client>/. The repo's own .claude/skills/ holds everything: the dozen skills that build client sites, and a handful of internal ones (start-of-session standup, end-of-session cleanup, the release command, the skill that edits skills).
When the agent walks up from workspaces/<client>/ looking for skills, it climbs straight into repo/.claude/skills/ and finds the lot. The client's build can now see — and invoke — our internal tooling. Settings were scoped; skills weren't.
Fix 1: give each workspace its own git root
The walk stops at "the repository root," and Claude Code defines that as the nearest .git. So we give each workspace its own:
# when a workspace is first created
git init -q workspaces/<client>/
Now the skill walk climbs from workspaces/<client>/, hits the workspace's own .git, and stops — it never reaches repo/.claude/skills/. A private git root per tenant turns "walks into the parent repo" into "stops at the boundary."
(If this rhymes with giving each agent its own worktree, it's the same instinct — a per-unit git root as an isolation boundary — applied to a different leak.)
Fix 2: make the one remaining door fail-closed
With the walk sealed, one intentional path remains: we copy the build skills into each workspace's .claude/skills/ on purpose, so the operator skills are present and the parent walk is unnecessary. That copy is now the way a skill reaches a client — which makes it the thing that needs guarding.
It was guarded by a denylist: copy everything except these few internal skills. That's fail-open — it works until someone adds a new internal skill and forgets to deny it, and now it's silently in every client workspace. We flipped it to an allowlist: copy only the named operator skills, nothing else.
# fail-open: every new skill ships to clients until you remember to block it
if skill.name in INTERNAL_SKILLS:
continue
# fail-closed: a skill reaches a client only if you opt it in
if skill.name not in OPERATOR_SKILLS:
continue
The difference is the default. With a denylist, forgetting leaks a skill. With an allowlist, forgetting withholds one — and that fails loud (a build that needs a skill can't run) instead of silent (internal tooling sitting in a client folder). For a tenant boundary, you want the failure mode that's noisy and safe.
What we'd tell you
Two things, if you're running Claude Code as a platform rather than a single dev's tool:
- Know which mechanism does what.
--setting-sourcesgoverns settings, hooks, and MCP. Skill availability is governed by the filesystem walk and your own copy step. Reach for the right lever for the boundary you actually want. - Default-deny the tenant edge. Anywhere an internal capability could reach a customer's context, make the default out. An allowlist you have to opt into beats a denylist you have to remember.
Neither fix is clever. Both are the kind of boundary you only notice you're missing once you go looking — so this is us looking, out loud.