Module 30 · 50 min

The App's Claude Is Not Your Claude

You can run the app on a machine full of personal hooks, skills and MCP servers, and prove none of them reached the agent.

Surface
CLAUDE_CONFIG_DIR · plugins · settingSources
Workbench tag
module-30
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-30

Your machine has a ~/.claude. By now it holds your hooks, your skills, your MCP servers, maybe an API key’s worth of credentials. The app you are building will also run on machines like that, owned by people who never read this course, and every one of those personal files is something the app’s agent must not touch. Module 20 asked whether an agent reads filesystem configuration at all. This module asks the sharper question: whose. The answer is a closed configuration, and it is four options set in one place.

flowchart TD
subgraph yours["your Claude Code"]
  direction TB
  a["~/.claude<br/>your hooks, skills, MCP"] --> b["your claude sessions"]
end
subgraph apps["the app's Claude"]
  direction TB
  c["app-data directory<br/>the app's plugin, the app's list"] --> d["the app's agents"]
end
yours ~~~ apps
Two Claudes on one machine. The wall has one gap, and module 20 named it: managed policy settings are read regardless.

Four options, one wall

The server builds every query() with all four, unconditionally, so no configuration word can reopen a door (the records for this round are 0030 through 0036 in the chat app’s repository). All four sit in one object literal, so read that object first; the citations to the SDK’s own type declarations are in the comments beside each option, where the code can be checked against them.

chat-workbench @ module-30/
├─ server/
│ └─ src/
└─ .claude-plugin/
server/src/sdk-stream.js

buildQueryOptions: every option the one query() call is given, in one object literal.

// Lines 1 to 52 elided: the module header, the SDK import, and settingSourcesFor. Full file: server/src/sdk-stream.js at tag module-30.

/**
* Build the `options` object the one `query()` call is given.
*
* Pulled out of `sdkStream` so the options can be read without calling the SDK.
*
* @param {SessionRequest} request
* @returns {Record<string, unknown>}
*/
export function buildQueryOptions(request) {
return {
  // The agent works in the workspace directory, never in the repository that
  // holds this server. See docs/decisions/0004.
  cwd: request.cwd,
  // The full Claude Code system prompt. Omitting `systemPrompt` gives the
  // SDK's minimal default, which covers tool calling and nothing else, an
  // agent that would not know it has a workspace. Declared at sdk.d.ts line
  // 2163 as `string | string[] | { ... }`. See docs/decisions/0005.
  systemPrompt: { type: "preset", preset: "claude_code" },
  // Verified against @anthropic-ai/claude-agent-sdk 0.3.251: sdk.d.ts line
  // 1828 declares `permissionMode?: PermissionMode`, line 2238 gives the
  // union `'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' |
  // 'dontAsk' | 'auto'`, and line 1826 describes `'dontAsk'` as "Don't prompt
  // for permissions, deny if not pre-approved". Record 0011 covers why this
  // version runs under it and what it costs.
  permissionMode: request.permissionMode,
  // Ask for the answer in pieces as the model writes it, rather than one
  // finished assistant message at the end. With this on the stream also
  // carries messages of type "stream_event", each holding one raw API event.
  // Verified against 0.3.251: sdk.d.ts line 1720 declares
  // `includePartialMessages?: boolean`, "When true,
  // `SDKPartialAssistantMessage` events will be emitted during streaming".
  includePartialMessages: true,
  // The environment the subprocess runs under, with `CLAUDE_CONFIG_DIR`
  // pinned to the directory this application owns.
  //
  // `Options.env` is declared at sdk.d.ts line 1520 as `env?: { [envVar:
  // string]: string | undefined }`, and its doc comment at lines 1504 to 1519
  // states the fact the whole arrangement turns on: "When set, this value
  // REPLACES the subprocess environment entirely, it is not merged with
  // `process.env`. Spread `process.env` yourself if the subprocess still
  // needs inherited variables like `PATH`, `HOME`, or `ANTHROPIC_API_KEY`."
  // `isolationEnv` in config.js does the spreading, and does the pinning
  // after it. Record 0031.
  env: request.env,
  // The plugin this session loads: this repository.
  //
  // `Options.plugins` is declared at sdk.d.ts line 1860 as
  // `plugins?: SdkPluginConfig[]`, its doc comment at lines 1847 to 1859 says
  // "Load plugins for this session. Plugins provide custom commands, agents,
  // skills, and hooks" and "Currently only local plugins are supported via
  // the 'local' type", and `SdkPluginConfig` at lines 4696 to 4709 is
  // `{ type: 'local', path: string, skipMcpDiscovery?: boolean }`, `type`
  // being the literal `'local'` at line 4700 and `path` "Absolute or relative
  // path to the plugin directory" at line 4704. Research 13, quoting
  // code.claude.com/docs/en/agent-sdk/plugins as fetched 2026-08-30, gives
  // the same shape and adds that the path "should point to the plugin's root
  // directory: the parent of `skills/`, `agents/`, `hooks/`, `commands/`, or
  // `.claude-plugin/`". That parent is the repository root. Record 0033.
  plugins: [{ type: "local", path: request.pluginPath }],
  // No MCP server this application did not list.
  //
  // `Options.strictMcpConfig` is declared at sdk.d.ts line 2105, and its doc
  // comment at lines 2096 to 2104 says: "Only use MCP servers passed via the
  // `mcpServers` option (and servers declared by explicitly-passed agent
  // definitions in `agents`), ignoring all other MCP configurations: project
  // `.mcp.json`, user settings, plugins, and on-disk agent frontmatter, 
  // including subagent frontmatter MCP."
  //
  // This server passes no `mcpServers` and no `agents`, so the app's list is
  // empty and the session connects nothing. Record 0034 works through what
  // that sentence excludes, one clause at a time.
  strictMcpConfig: true,
  // Which settings files the session reads.
  //
  // Verified against @anthropic-ai/claude-agent-sdk 0.3.251, sdk.d.ts: the
  // doc comment at lines 2047 to 2055 says "Control which filesystem settings
  // to load", lists `'user'` (`~/.claude/settings.json`), `'project'`
  // (`.claude/settings.json`) and `'local'` (`.claude/settings.local.json`),
  // and states three facts this server depends on: "When omitted, all sources
  // are loaded (matches CLI defaults)", "Pass `[]` to disable filesystem
  // settings (SDK isolation mode)", and "Must include `'project'` to load
  // CLAUDE.md files". The option itself is line 2056, and `SettingSource` is
  // `'user' | 'project' | 'local'` at line 8032.
  //
  // A present key holding an empty array is not the same as an absent key,
  // and it is not the same as `settingSources: undefined` either, the doc
  // comment's "omitted" means the key is not there. So "inherited" spreads an
  // empty object rather than setting the key to anything. Records 0012 and
  // 0013.
  //
  // "app", the only value this version produces, names two of the three
  // members and leaves out `'user'`. `'user'` is `~/.claude/settings.json`:
  // the person's own layer, the one thing a closed configuration cannot read.
  // `'project'` and `'local'` are files that live beside the code the app
  // ships. Record 0032, which also says what `'project'` resolving relative
  // to `cwd` costs here.
  ...settingSourcesFor(request.settings),
};
}

// sdkStream, which calls query() with these options, follows.
Read more

The file is the only one in the server that imports @anthropic-ai/claude-agent-sdk. Below this function, sdkStream calls query() with an async iterable prompt and returns messages, interrupt and close.

Three files at the module-30 tag. The options object first, then the two functions and the manifest it points at.

The first is env. The SDK’s env option replaces the subprocess’s environment outright, so the server spreads its own environment first, then pins one variable on top: CLAUDE_CONFIG_DIR, pointed at a directory the app creates under the platform’s application-data location. Order matters, and record 0031 proves it the right way: the test that asserts “the pin wins over a CLAUDE_CONFIG_DIR the person exported” was validated by inverting the spread and the pin and watching it fail. CLAUDE_CONFIG_DIR is the variable that moves the whole config home, credentials and history included, so the app’s agent has its own home and your ~/.claude is not it.

The second is settingSources: ["project", "local"]. Not the empty array the module 29 server shipped, and not the omission that means “read everything”: the app names the two sources that belong to the app and leaves out 'user', because 'user' is ~/.claude/settings.json, the person’s layer, and the person’s layer is exactly what this module exists to exclude.

The third is plugins: [{ type: "local", path: <this repository> }]. The repository now carries .claude-plugin/plugin.json with a name and nothing else, which makes the app’s own repo a loadable plugin. It is a skeleton today; the coming modules fill it with the skills, agents, and hooks the app ships, and this is the line that will carry them into every session.

The fourth is strictMcpConfig: true, re-derived for this app in record 0034: only the MCP servers the app passes exist, and a .mcp.json in the workspace or the person’s settings gives the agent nothing.

The banner tells the person all of this in one sentence. ready.settings now says "app", and the client renders it as “this app’s own configuration steers this agent, nothing from your ~/.claude”, with an unknown word still quoted back rather than guessed (record 0042).

What the records refuse to claim

Fake mode reports "app" and simulates none of it: no subprocess spawns, so nothing receives the pinned variable, and the app-data directory after a fake first boot is empty, which the server’s README states after looking rather than implying otherwise. Every live effect here, whether the pin excludes ~/.claude, whether the plugin loads, whether a name-only manifest is accepted, is cited to the SDK’s type declarations and listed unverified, because no API key exists on the machine this was built on.

One open question is carried forward in the open rather than papered over. 'project' as a setting source resolves relative to a directory, and if that directory is the agent-writable workspace, then module 20’s write-settings-now, obeyed-next-session loop reopens the day this repository ships a .claude/ directory. Record 0032 states the problem and the likely answer, app settings living beside the app’s code rather than beside the workspace, and assigns it to the module that first ships one.

See the wall

git checkout module-30
WORKBENCH_FAKE_SDK=1 npm run dev

The banner’s last sentence changes, the startup log names the app-data directory and the plugin path, and the module 29 behavior is untouched: same queue, same boundary, same frames, which the demo transcript in the server README shows end to end.

Build

The ladder:

  1. Run it. Read the banner’s new sentence, then the startup log line naming appData and plugin.
  2. Read one file. server/src/config.js , the appDataRoot and isolationEnv functions, with records 0030 and 0031 beside them.
  3. Change one line and see it. Start the server with WORKBENCH_APP_DATA=./my-app-data and watch the directory appear where you said, created at boot so a permissions problem is a startup crash instead of a first-message mystery.
  4. Build. With a real API key, the proof the module’s goal promises: write a one-line SessionStart hook in your own ~/.claude that prints a marker, run a session in the app, and confirm the marker never appears. Then remove the env option from buildQueryOptions, run again, and watch your personal hook fire inside the app. Put it back.
The mistake most people make first

You skip the config-dir pin because everything works without it. It does work. That is the trap. With no pin, the subprocess reads the default home: the agent’s tool list quietly carries every MCP server from your ~/.claude.json, your personal SessionStart hooks run inside every conversation the app starts, and your credentials are the ones being spent. Nothing reports a leak, because from Claude Code’s point of view nothing is wrong, reading the home directory is what it is for. The person who installs your app on their machine gets the same silence with their files.

The proof discipline is the fix, and it is the module’s goal sentence: do not believe the wall, test the wall. The marker-hook exercise in the Build ladder is that test, and the reason the fourth rung needs a real key is honest: fake mode spawns nothing, so fake mode can neither leak nor prove the absence of one. This is module 14’s -p lesson wearing app clothes: a session that inherits a machine’s configuration inherits all of it, and the only safe default for software you give to other people is a home the app owns.

Does this travel?

Entirely, as a habit: every harness has a home directory it reads by default, and “which home does my subprocess read” is a question to ask of all of them. The variable name, the three-source list, the plugin option, and the app-data conventions are this SDK’s and this platform’s. The proof pattern, plant a marker in the excluded configuration and show it never fires, works everywhere and costs one line.

Check yourself

  1. A teammate reads isolationEnv and asks why it spreads process.env at all instead of passing only the pin, “for better isolation”. What breaks under their version, and which record’s test proves the pin still wins under yours?
  2. The app runs on a machine whose owner has an elaborate ~/.claude full of MCP servers. Name each of the four options that stands between those servers and the app’s agent, and what each one blocks on its own.
  3. Why does record 0032 call 'project' in the setting sources a question rather than a defect, and what event turns it into one that must be answered?