feat(#1969): ship claude settings via /etc/claude-code/managed-settings.json

This commit is contained in:
damocles 2026-06-26 21:36:16 +02:00 committed by mara
commit b231ed2392
6 changed files with 49 additions and 65 deletions

View file

@ -135,13 +135,22 @@ if a new inbox message arrives before this turn ends"). The
``` ```
claude --print --verbose --output-format stream-json --model <name> \ claude --print --verbose --output-format stream-json --model <name> \
--continue --settings /run/hive/claude-settings.json \ --effort <level> --continue \
--system-prompt-file /run/hive/claude-system-prompt.md \ --system-prompt-file /run/hive/claude-system-prompt.md \
--mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \ --mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \
--tools <builtins> --allowedTools <builtins+mcp> --tools <builtins> --allowedTools <builtins+mcp>
# wake prompt piped over stdin # wake prompt piped over stdin
``` ```
Static settings are no longer passed with `--settings`: hive-enforced
settings ship at `/etc/claude-code/managed-settings.json` (claude-code's
canonical managed-settings path — precedence #1, read-only,
un-overridable), wired in `nix/templates/harness-base.nix` from the
`prompts/claude-settings.json` asset. `effortLevel` is deliberately not
in that file — effort is controlled live via the `--effort` flag
(`HIVE_DEFAULT_EFFORT` / the per-agent UI slider), which managed scope
would otherwise lock.
`<name>` is read from `Bus::model()` on each turn. The initial `<name>` is read from `Bus::model()` on each turn. The initial
default is set by `hyperhive.model` in the agent's `agent.nix` default is set by `hyperhive.model` in the agent's `agent.nix`
(NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls (NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls
@ -172,9 +181,9 @@ percentage-of-window ctx badge.
`--continue` keeps a persistent session per agent (claude stores `--continue` keeps a persistent session per agent (claude stores
sessions in `~/.claude/projects/`, which is bind-mounted sessions in `~/.claude/projects/`, which is bind-mounted
persistently). Auto-compact and auto-memory are disabled via persistently). Auto-compact and auto-memory are disabled via the
`--settings` because hyperhive owns compaction — see managed settings at `/etc/claude-code/managed-settings.json` because
[Compaction](#compaction) below. hyperhive owns compaction — see [Compaction](#compaction) below.
A one-shot `--continue` suppression is available via A one-shot `--continue` suppression is available via
`POST /api/new-session` (or `/new-session` slash command in the `POST /api/new-session` (or `/new-session` slash command in the
per-agent terminal) — `Bus::take_skip_continue()` flips an per-agent terminal) — `Bus::take_skip_continue()` flips an
@ -183,8 +192,9 @@ per-agent terminal) — `Bus::take_skip_continue()` flips an
### Compaction ### Compaction
claude's own in-session auto-compact is off (`--settings`); hyperhive claude's own in-session auto-compact is off (via the managed settings
owns it explicitly in `turn::drive_turn`. There are two triggers: at `/etc/claude-code/managed-settings.json`); hyperhive owns it
explicitly in `turn::drive_turn`. There are two triggers:
- **Reactive** — claude-code prints `Prompt is too long` (the - **Reactive** — claude-code prints `Prompt is too long` (the
`PROMPT_TOO_LONG_MARKER`). The session is *already* past the context `PROMPT_TOO_LONG_MARKER`). The session is *already* past the context
@ -255,14 +265,12 @@ next turn picks it up like any other inbox message.
### On-boot files ### On-boot files
`hive_ag3nt::turn::write_*` writes three files next to the per-agent `hive_ag3nt::turn::write_*` writes two files next to the per-agent
socket at `/run/hive/` once at startup: socket at `/run/hive/` once at startup:
- `claude-mcp-config.json` — re-invokes the running binary as `mcp` - `claude-mcp-config.json` — re-invokes the running binary as `mcp`
child (so the same binary serves as harness + as claude's MCP child (so the same binary serves as harness + as claude's MCP
child process). child process).
- `claude-settings.json` — the `--settings` blob (auto-compact and
auto-memory off, effortLevel medium).
- `claude-system-prompt.md` — rendered from - `claude-system-prompt.md` — rendered from
`hive-ag3nt/prompts/system.md` by `hive_ag3nt::prompt::render`: `hive-ag3nt/prompts/system.md` by `hive_ag3nt::prompt::render`:
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`, HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`,
@ -620,8 +628,9 @@ status hint moved to the wake prompt + UI header.
- Allowed built-ins: `Edit`, `Glob`, `Grep`, `Read`, `Write`. - Allowed built-ins: `Edit`, `Glob`, `Grep`, `Read`, `Write`.
- Tool-group-gated built-ins: `WebFetch`, `WebSearch` (added when the - Tool-group-gated built-ins: `WebFetch`, `WebSearch` (added when the
`web_tools` tool group is enabled — see P3RM1SS10NS tab). `web_tools` tool group is enabled — see P3RM1SS10NS tab).
- Denied by omission or `claude-settings.json` deny list: `Bash`, - Denied by omission or the managed-settings deny list
`Task`, `NotebookEdit`, `TodoWrite`. (`/etc/claude-code/managed-settings.json`): `Bash`, `Task`,
`NotebookEdit`, `TodoWrite`.
- Allowed MCP tools: as listed above (by tool group). - Allowed MCP tools: as listed above (by tool group).
`Bash` is disallowed — shell execution goes through `Bash` is disallowed — shell execution goes through

View file

@ -1,7 +1,6 @@
{ {
"autoCompactEnabled": false, "autoCompactEnabled": false,
"autoMemoryEnabled": false, "autoMemoryEnabled": false,
"effortLevel": "medium",
"permissions": { "permissions": {
"deny": ["Bash", "Task", "TodoWrite"] "deny": ["Bash", "Task", "TodoWrite"]
} }

View file

@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use anyhow::{Context, Result, bail}; use anyhow::{Result, bail};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command; use tokio::process::Command;
@ -17,12 +17,13 @@ use crate::events::{Bus, LiveEvent};
use crate::login::LoginState; use crate::login::LoginState;
use crate::mcp; use crate::mcp;
// `--settings` JSON is read at runtime from // Hive-enforced claude settings ship at `/etc/claude-code/managed-settings.json`
// `$HIVE_ASSETS_DIR/prompts/claude-settings.json` via // (wired in `nix/templates/harness-base.nix` from the `prompts/claude-settings.json`
// `hive_sh4re::assets::claude_settings()`. We turn off claude's // asset). claude-code auto-discovers that managed path — precedence #1,
// in-session auto-compaction and its cross-session auto-memory because // read-only, un-overridable — so the harness no longer passes `--settings`.
// hyperhive owns those concerns (`/compact` on overflow, notes // We turn off claude's in-session auto-compaction and its cross-session
// persistence under `/state`). Unknown keys are silently ignored by // auto-memory because hyperhive owns those concerns (`/compact` on overflow,
// notes persistence under `/state`). Unknown keys are silently ignored by
// claude-code; if a key gets renamed we'll spot it because the // claude-code; if a key gets renamed we'll spot it because the
// corresponding behavior will start firing mid-turn again. // corresponding behavior will start firing mid-turn again.
@ -95,8 +96,10 @@ conversation to go on. Do not start new work or reply to anyone — just write y
and end the turn."; and end the turn.";
/// The set of files claude reads on every invocation: the MCP server /// The set of files claude reads on every invocation: the MCP server
/// config (`--mcp-config`), static settings (`--settings`), and the /// config (`--mcp-config`) and the pre-rendered role/tools system
/// pre-rendered role/tools system prompt (`--system-prompt-file`). /// prompt (`--system-prompt-file`). Static settings are no longer
/// passed here — they live at `/etc/claude-code/managed-settings.json`
/// and claude auto-discovers them.
/// Materialised once at harness startup; shared between the turn loop /// Materialised once at harness startup; shared between the turn loop
/// and the operator-driven `/compact` path so both invocations look /// and the operator-driven `/compact` path so both invocations look
/// identical to claude (same MCP surface, same allowed tools, same /// identical to claude (same MCP surface, same allowed tools, same
@ -104,7 +107,6 @@ and end the turn.";
#[derive(Clone)] #[derive(Clone)]
pub struct TurnFiles { pub struct TurnFiles {
pub mcp_config: PathBuf, pub mcp_config: PathBuf,
pub settings: PathBuf,
pub system_prompt: PathBuf, pub system_prompt: PathBuf,
} }
@ -118,7 +120,6 @@ impl TurnFiles {
pub async fn prepare(socket: &Path, label: &str) -> Result<Self> { pub async fn prepare(socket: &Path, label: &str) -> Result<Self> {
Ok(Self { Ok(Self {
mcp_config: write_mcp_config(socket).await?, mcp_config: write_mcp_config(socket).await?,
settings: write_settings(socket).await?,
system_prompt: write_system_prompt(socket, label).await?, system_prompt: write_system_prompt(socket, label).await?,
}) })
} }
@ -145,33 +146,6 @@ pub async fn write_mcp_config(socket: &Path) -> Result<PathBuf> {
Ok(path) Ok(path)
} }
/// Drop the static `--settings` JSON next to the MCP config so we can
/// pass a path (`--settings <file>`) instead of an ever-growing inline
/// blob — the CLI argv has a finite length budget.
///
/// # Errors
///
/// Returns an error if the settings file cannot be written.
pub async fn write_settings(_socket: &Path) -> Result<PathBuf> {
let parent = crate::paths::config_dir();
tokio::fs::create_dir_all(&parent).await.ok();
let path = parent.join("claude-settings.json");
// Source-of-truth is `$HIVE_ASSETS_DIR/prompts/claude-settings.json`;
// copy through the per-agent runtime dir so claude reads it from the
// same socket-adjacent location every time and so a future override
// (per-agent settings JSON layer) drops in cleanly.
let src = hive_sh4re::assets::claude_settings();
tokio::fs::copy(&src, &path).await.with_context(|| {
format!(
"copy claude settings from {} to {}",
src.display(),
path.display()
)
})?;
tracing::info!(path = %path.display(), "wrote claude settings");
Ok(path)
}
/// Thin re-export of [`crate::prompt::write_system_prompt`] for /// Thin re-export of [`crate::prompt::write_system_prompt`] for
/// callers that already import this module. The actual rendering + /// callers that already import this module. The actual rendering +
/// marker-block logic lives in `prompt.rs`; this is just the public /// marker-block logic lives in `prompt.rs`; this is just the public
@ -579,8 +553,9 @@ fn session_refreshed(prev: DirSnapshot, now: DirSnapshot) -> bool {
/// live event bus. Prompt goes over stdin (variadic /// live event bus. Prompt goes over stdin (variadic
/// `--allowedTools`/`--tools` would otherwise eat a trailing positional /// `--allowedTools`/`--tools` would otherwise eat a trailing positional
/// prompt). The session is persistent across turns via `--continue` and /// prompt). The session is persistent across turns via `--continue` and
/// claude's in-session auto-compact is disabled via `--settings` so it /// claude's in-session auto-compact is disabled via the managed
/// doesn't stall mid-turn — hyperhive owns compaction. /// settings at `/etc/claude-code/managed-settings.json` so it doesn't
/// stall mid-turn — hyperhive owns compaction.
pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome { pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome {
match run_claude(prompt, files, bus).await { match run_claude(prompt, files, bus).await {
Ok((true, _, _)) => TurnOutcome::PromptTooLong, Ok((true, _, _)) => TurnOutcome::PromptTooLong,
@ -675,9 +650,7 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool,
.arg("--model") .arg("--model")
.arg(&model) .arg(&model)
.arg("--effort") .arg("--effort")
.arg(&effort) .arg(&effort);
.arg("--settings")
.arg(&files.settings);
if resume { if resume {
cmd.arg("--continue"); cmd.arg("--continue");
} }

View file

@ -51,9 +51,9 @@ struct AppState {
bus: Bus, bus: Bus,
socket: PathBuf, socket: PathBuf,
/// Same `TurnFiles` the harness's turn loop uses. Shared so /// Same `TurnFiles` the harness's turn loop uses. Shared so
/// `/api/compact` re-uses the exact MCP config / system prompt / /// `/api/compact` re-uses the exact MCP config / system prompt
/// settings claude saw on the last regular turn — keeps the /// claude saw on the last regular turn — keeps the session shape
/// session shape identical across compact + normal turns. /// identical across compact + normal turns.
files: TurnFiles, files: TurnFiles,
/// Prevents `/api/compact` from racing with an in-flight normal turn. /// Prevents `/api/compact` from racing with an in-flight normal turn.
turn_lock: TurnLock, turn_lock: TurnLock,

View file

@ -90,10 +90,3 @@ pub fn config_org_avatar_png() -> PathBuf {
pub fn prompt_template() -> PathBuf { pub fn prompt_template() -> PathBuf {
dir().join("prompts/system.md") dir().join("prompts/system.md")
} }
/// `$HIVE_ASSETS_DIR/prompts/claude-settings.json` — the static
/// `--settings` JSON every claude invocation reads.
#[must_use]
pub fn claude_settings() -> PathBuf {
dir().join("prompts/claude-settings.json")
}

View file

@ -1066,6 +1066,16 @@ in
environment.etc."hyperhive/claude-plugins-auto-update.json".text = environment.etc."hyperhive/claude-plugins-auto-update.json".text =
builtins.toJSON config.hyperhive.claudePluginsAutoUpdate; builtins.toJSON config.hyperhive.claudePluginsAutoUpdate;
# Hive-enforced claude settings. claude-code auto-discovers managed
# settings at this canonical Linux path (precedence #1, read-only,
# un-overridable by user/project/CLI) — so the harness no longer
# passes `--settings` or copies the blob per turn. effortLevel is
# deliberately NOT shipped here: effort is controlled live via the
# `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the per-agent UI slider),
# which managed scope would otherwise override and lock.
environment.etc."claude-code/managed-settings.json".source =
"${pkgs.hyperhive-assets}/share/hyperhive/prompts/claude-settings.json";
# Merged frontend static tree. Base = `${frontend.dist}/agent/`, # Merged frontend static tree. Base = `${frontend.dist}/agent/`,
# then each `extraFiles` entry is laid on top at its `target` # then each `extraFiles` entry is laid on top at its `target`
# path. The runCommand derivation aborts on overwrite so a # path. The runCommand derivation aborts on overwrite so a