feat: per-group harness capability toggles — lean defaults for new groups, existing grandfathered#2983
Closed
gabi-simons wants to merge 11 commits into
Closed
Conversation
This was referenced Jul 9, 2026
47b1a48 to
60d1324
Compare
e299afb to
3d4cb63
Compare
60d1324 to
ad2bc65
Compare
3d4cb63 to
c8178b1
Compare
ad2bc65 to
7ce25de
Compare
c8178b1 to
c0a4dcb
Compare
… reconciler harness_capabilities JSON column (migration 019) stores sparse per-group overrides; code defaults live in src/harness-capabilities.ts (agent-teams off, workflow off). configFromDb materializes the RESOLVED map into container.json. reconcileHarnessSettings converges each group's settings.json on every spawn — manages exactly the teams env key and disableWorkflows, preserves everything else, write-if-changed with tmp+rename. The teams key leaves DEFAULT_SETTINGS_JSON: managed keys enter settings.json only through the reconciler, so pre-existing groups converge to default-off at their next spawn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion gate groups config update gains --harness-capabilities 'k=on|off|default[,...]' (JSON-column read-modify-write, validated against the registry; a harness-only update passes the nothing-to-update guard). config get renders raw overrides plus a resolved view with (default)/(override) markers. dispatch blocks the arg from group-scoped agents exactly like cli_scope — an agent cannot re-enable capabilities its operator turned off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ok blocklist container.json's resolved harnessCapabilities map threads RunnerConfig → ProviderOptions → ClaudeProvider. The fixed disallow set gains DesignSync (desktop tool, nothing to sync with headless; 9.3KB/turn schema); buildDisallowedTools adds Workflow unless workflow=on — defense-in-depth behind the host-reconciled disableWorkflows settings key, fail-closed on absent/garbage state. preToolUseHook becomes a factory so the hook blocks the same per-instance list. Unknown resolved keys warn at construction; agent-teams is known-but-host-managed so it never warns. Scheduling instructions gain the harness-task-list vs schedule_task naming note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… db-central + CLAUDE.md rows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ainer.json resolved Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…esty, altitude)
Correctness:
- reconciler + PreCompact hook unified into one read-modify-write with a
single atomic write, and the parsed value is shape-validated: a settings.json
holding valid-JSON-non-object (null, a scalar, {"env":"bad"}) no longer
throws a TypeError that bricked the group — it warns and skips, like the old
hook. Fixes divergent atomic/non-atomic writers and a double read per spawn.
- config update wraps both DB writes in one transaction — a failing scalar
update can no longer leave the harness override half-applied.
- parseHarnessCapabilitiesArg returns a single key->directive map, so a repeated
key resolves last-wins (was: clears always beat sets regardless of order).
- validation uses Object.hasOwn, not `key in` — inherited names like
`constructor`/`toString` are no longer accepted as capabilities.
- createContainerConfig now binds cli_scope + harness_capabilities (were
silently dropped to the column default).
Security honesty:
- agent-teams=off is documented as spawn-time hygiene, not a hard in-container
boundary: settings.json is RW-mounted so an agent can rewrite it for the
container lifetime. workflow=off keeps its disallowedTools backstop. Follow-up
filed to mount the managed settings source read-only.
- dispatch escalation gate protects canonical field names and normalizes each
incoming arg key — no spelling-variant bypass.
Altitude / cleanup:
- capabilities carry their host mechanism in the registry; the reconciler
iterates it, so adding a key is one registry entry (+ one runner map line if
it blocks a tool). Runner unknown-key lockstep + warning loop deleted (host
drops unknown keys at resolve; warns once per distinct problem, not per spawn).
- resolved view is structured {state, source}; source is 'override' only for a
VALID stored override (an invalid value reports 'default', not a lie).
- ReportFindings joins the fixed disallow list (headless has no UI to receive
it; ~1.9KB/turn). PreToolUse hook built once in the constructor with a Set.
- drift guard: assert the installed SDK version too (SDK now pinned exactly),
and dual-capture the fixture (tools vs toolsBare). This EMPIRICALLY corrected
a prior claim: allowedTools is fully inert here (surfaces are byte-identical),
it does not promote Glob/Grep — comment fixed to match.
Host 663 + container 125 green; wire re-probed (default drops
Workflow/DesignSync/ReportFindings; workflow=on restores only Workflow).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… non-breaking
Migration 019 now stamps existing container_configs rows with
{agent-teams:on, workflow:on} — their pre-feature behavior — so upgrading
changes nothing for current groups. Only rows inserted after the migration
(new groups; every group on a fresh install, which has none yet) get the
lean column default. Operators opt existing groups into the lean defaults
per group via ncl.
Verified e2e on a real main->branch upgrade: existing groups keep teams +
Workflow through the full reconcile+spawn path (settings.json retains the
teams env key, no disableWorkflows, container.json shows both on); a new
group gets {}. Drops both [BREAKING] CHANGELOG entries — the change is now
non-breaking. Also corrects the allowlist CHANGELOG line (allowedTools is
fully inert, not Glob/Grep-promoting) and notes the added SDK-pin guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t reach
Migration 019 grandfathers rows that exist when it runs, but two paths
sidestep it and silently flipped pre-existing groups to the lean
defaults:
- A legacy group with no container_configs row at upgrade time gets its
row from the startup backfill, which ran AFTER the migration and
stamped '{}'. The backfill now stamps the same legacy all-on state —
any group it touches by definition pre-dates the feature.
- During the update window (bind-mounted runner source already new, old
host still running), container.json lacks the harnessCapabilities
field and the runner defaulted it to {} = all-off. A missing field can
only mean a pre-capability host (new hosts always emit the resolved
map), so the runner now defaults it to legacy all-on.
Regression tests cover both: the migration→backfill boot sequence, and
the raw-config mapping (extracted as configFromRaw for testability).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eads on main Uniqueness is keyed on the migration name, so installs that already ran the branch build are unaffected; the renumber keeps the version ordering hint and filename honest. Also adds the missing db-central row for main's 019-wiring-threads while touching that table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dfather paths, deny feedback, doc honesty Code: - applyGroupSettings never throws: a non-SyntaxError I/O failure on settings.json (EISDIR/EACCES — the dir is mounted rw into the container) used to escape initGroupFilesystem and wedge the group in a wakeContainer retry-fail loop; pre-capability code swallowed all errors on this path. Warn-and-spawn restored, regression test added. - setup/register.ts now runs the container-config backfill right after migrations, matching the host boot order — otherwise its ensureContainerConfig could claim a legacy group's row with lean defaults before the backfill could grandfather it. - scripts/seed-discord.ts provisions the config row at creation so the later backfill can't mis-grandfather a genuinely new group. - The PreToolUse deny now puts the redirect in `reason` / permissionDecisionReason — the fields the CLI feeds back to the model (stopReason only surfaces with continue:false, which ends the turn). - New tests: ClaudeProvider builds its blocklist from constructor capabilities (guards the query-site seam), and a structural guard that spawnContainer threads harnessCapabilities into group init. Docs (measured-behavior honesty): - disallowedTools schema-stripping is best-effort on the pinned CLI (wire-measured per-query nondeterminism for flag-gated tools); the PreToolUse hook is the deterministic block. Enforcement-strength section, agent-runner-details inventory, and CHANGELOG updated. - agent-teams=off bypass via workspace project/local settings persists across respawns (reconciler manages only the user-scope file) — said so, with the RO-mount/settingSources follow-up noted. - ReportFindings added to the fixed-off inventory; in-container rejection claim scoped to cli_scope=group; verify-after-upgrade bullet now quotes the real config-get output; hand-edited settings.json takeover documented with the supported re-apply path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reconciler's env mechanism (and the PreCompact ensure's hooks
parent) created the managed parent with `asObject(x) ?? (x = {})` —
which silently REPLACED an existing non-object value, e.g.
{"env":"operator-managed-value"} became an object when agent-teams=on
wrote its key. That violates the contract that unmanaged settings
content is never touched, malformed or not. Managed parents are now
absent → created, wrong shape → warn-and-skip that mechanism (other
mechanisms still apply). Regression tests cover the exact reported
input for env and the analogous hooks case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7ce25de to
d548980
Compare
c0a4dcb to
cf023a0
Compare
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.
Why
NanoClaw already disables Claude Code's cron/scheduling builtins in favor of its own
ncl tasksscheduler — one authoritative path, no dormant duplicate. This extends that to two more harness capabilities NanoClaw has its own equivalent for, and moves the on/off decision into per-group config.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) — overlaps NanoClaw's agent-to-agent messaging (create_agent+ destinations); multiplies separately-billed agent contexts invisibly to the host.New groups default these off, cutting ~20% of per-turn context (−42.9% at the SDK tool-schema level, measured on claude-code 2.1.197).
Non-breaking
Every existing group is grandfathered to its prior behavior (teams + Workflow on) on every path a config row can reach an upgraded install: migration 020 stamps existing rows, the startup backfill (also run by the setup
registerstep) stamps legacy groups whose row is only created later, and the runner treats acontainer.jsonmissing the field (written by a pre-upgrade host mid-update) as legacy all-on. Upgrading changes nothing for current agents; operators who had hand-edited a group'ssettings.jsonre-apply that intent with onenclcommand (documented). Only newly-created groups — and every group on a fresh install — get the lean defaults via the column default. Operators opt an existing group into the lean defaults (or toggle either way) with one command:How it works
container_configs.harness_capabilities(migration 020 — renumbered after019-wiring-threadslanded on main) holds per-group overrides; code defaults live insrc/harness-capabilities.ts. Each capability carries its host mechanism in the registry, so adding a key is one entry.container.json; a reconciler insrc/group-init.tsbrings each group'ssettings.jsonto that state at spawn (managed keys only — operator edits and unmanaged keys are preserved).workflow=offalso blocks the tool at the runner as defense-in-depth behind the settings key: the PreToolUse hook is the deterministic invocation block (it now feeds the model a redirect to NanoClaw's equivalent via the deny-reason fields), whiledisallowedToolsschema-stripping is best-effort on the pinned CLI (wire-measured per-query nondeterminism for flag-gated tools — documented in the drift-guard header and docs).ncl groups config getshows raw overrides plus a resolved{state, source}view. Group-scoped agents can't change the field (same gate ascli_scope); write verbs stay admin-approval-gated.Note:
agent-teams=offis enforced by the group'ssettings.json, which is mounted read-write, so it is spawn-time hygiene rather than a hard in-container boundary (workflow=offkeeps its runner backstop). See docs/harness-capabilities.md.Testing
Host + container unit/integration suites (registry, migration incl. grandfather, backfill, and fresh-install paths, reconciler incl. legacy convergence and malformed-file tolerance, ncl round-trip, escalation gates, disallow composition, drift guard). Wire-probed the real provider (default drops Workflow/DesignSync/ReportFindings;
workflow=onrestores only Workflow) and ran a livemain→branch upgrade confirming existing groups keep their behavior and new groups come up lean.Stacked on #2982 — retargets to
mainwhen it merges.🤖 Generated with Claude Code