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> \
--continue --settings /run/hive/claude-settings.json \
--effort <level> --continue \
--system-prompt-file /run/hive/claude-system-prompt.md \
--mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \
--tools <builtins> --allowedTools <builtins+mcp>
# 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
default is set by `hyperhive.model` in the agent's `agent.nix`
(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
sessions in `~/.claude/projects/`, which is bind-mounted
persistently). Auto-compact and auto-memory are disabled via
`--settings` because hyperhive owns compaction — see
[Compaction](#compaction) below.
persistently). Auto-compact and auto-memory are disabled via the
managed settings at `/etc/claude-code/managed-settings.json` because
hyperhive owns compaction — see [Compaction](#compaction) below.
A one-shot `--continue` suppression is available via
`POST /api/new-session` (or `/new-session` slash command in the
per-agent terminal) — `Bus::take_skip_continue()` flips an
@ -183,8 +192,9 @@ per-agent terminal) — `Bus::take_skip_continue()` flips an
### Compaction
claude's own in-session auto-compact is off (`--settings`); hyperhive
owns it explicitly in `turn::drive_turn`. There are two triggers:
claude's own in-session auto-compact is off (via the managed settings
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
`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
`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:
- `claude-mcp-config.json` — re-invokes the running binary as `mcp`
child (so the same binary serves as harness + as claude's MCP
child process).
- `claude-settings.json` — the `--settings` blob (auto-compact and
auto-memory off, effortLevel medium).
- `claude-system-prompt.md` — rendered from
`hive-ag3nt/prompts/system.md` by `hive_ag3nt::prompt::render`:
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`.
- Tool-group-gated built-ins: `WebFetch`, `WebSearch` (added when the
`web_tools` tool group is enabled — see P3RM1SS10NS tab).
- Denied by omission or `claude-settings.json` deny list: `Bash`,
`Task`, `NotebookEdit`, `TodoWrite`.
- Denied by omission or the managed-settings deny list
(`/etc/claude-code/managed-settings.json`): `Bash`, `Task`,
`NotebookEdit`, `TodoWrite`.
- Allowed MCP tools: as listed above (by tool group).
`Bash` is disallowed — shell execution goes through

View file

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

View file

@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use anyhow::{Result, bail};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
@ -17,12 +17,13 @@ use crate::events::{Bus, LiveEvent};
use crate::login::LoginState;
use crate::mcp;
// `--settings` JSON is read at runtime from
// `$HIVE_ASSETS_DIR/prompts/claude-settings.json` via
// `hive_sh4re::assets::claude_settings()`. We turn off claude's
// in-session auto-compaction and its cross-session auto-memory because
// hyperhive owns those concerns (`/compact` on overflow, notes
// persistence under `/state`). Unknown keys are silently ignored by
// Hive-enforced claude settings ship at `/etc/claude-code/managed-settings.json`
// (wired in `nix/templates/harness-base.nix` from the `prompts/claude-settings.json`
// asset). claude-code auto-discovers that managed path — precedence #1,
// read-only, un-overridable — so the harness no longer passes `--settings`.
// We turn off claude's in-session auto-compaction and its cross-session
// 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
// 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.";
/// The set of files claude reads on every invocation: the MCP server
/// config (`--mcp-config`), static settings (`--settings`), and the
/// pre-rendered role/tools system prompt (`--system-prompt-file`).
/// config (`--mcp-config`) and the pre-rendered role/tools system
/// 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
/// and the operator-driven `/compact` path so both invocations look
/// identical to claude (same MCP surface, same allowed tools, same
@ -104,7 +107,6 @@ and end the turn.";
#[derive(Clone)]
pub struct TurnFiles {
pub mcp_config: PathBuf,
pub settings: PathBuf,
pub system_prompt: PathBuf,
}
@ -118,7 +120,6 @@ impl TurnFiles {
pub async fn prepare(socket: &Path, label: &str) -> Result<Self> {
Ok(Self {
mcp_config: write_mcp_config(socket).await?,
settings: write_settings(socket).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)
}
/// 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
/// callers that already import this module. The actual rendering +
/// 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
/// `--allowedTools`/`--tools` would otherwise eat a trailing positional
/// prompt). The session is persistent across turns via `--continue` and
/// claude's in-session auto-compact is disabled via `--settings` so it
/// doesn't stall mid-turn — hyperhive owns compaction.
/// claude's in-session auto-compact is disabled via the managed
/// 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 {
match run_claude(prompt, files, bus).await {
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("--effort")
.arg(&effort)
.arg("--settings")
.arg(&files.settings);
.arg(&effort);
if resume {
cmd.arg("--continue");
}

View file

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

View file

@ -90,10 +90,3 @@ pub fn config_org_avatar_png() -> PathBuf {
pub fn prompt_template() -> PathBuf {
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 =
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/`,
# then each `extraFiles` entry is laid on top at its `target`
# path. The runCommand derivation aborts on overwrite so a