Skip to content

feat: per-group harness capability toggles — lean defaults for new groups, existing grandfathered#2983

Closed
gabi-simons wants to merge 11 commits into
fix/claude-allowlist-reconciliationfrom
feat/harness-capabilities
Closed

feat: per-group harness capability toggles — lean defaults for new groups, existing grandfathered#2983
gabi-simons wants to merge 11 commits into
fix/claude-allowlist-reconciliationfrom
feat/harness-capabilities

Conversation

@gabi-simons

@gabi-simons gabi-simons commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Why

NanoClaw already disables Claude Code's cron/scheduling builtins in favor of its own ncl tasks scheduler — 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.

  • Agent teams (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) — overlaps NanoClaw's agent-to-agent messaging (create_agent + destinations); multiplies separately-billed agent contexts invisibly to the host.
  • Workflow tool — duplicates NanoClaw's session/a2a orchestration and is the single largest tool schema on every request.
  • DesignSync — a desktop-integration tool with nothing to sync with headless; fixed-off, no toggle.

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 register step) stamps legacy groups whose row is only created later, and the runner treats a container.json missing 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's settings.json re-apply that intent with one ncl command (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:

ncl groups config update --id <g> --harness-capabilities 'agent-teams=off,workflow=off'
ncl groups restart --id <g>

How it works

  • One sparse JSON column container_configs.harness_capabilities (migration 020 — renumbered after 019-wiring-threads landed on main) holds per-group overrides; code defaults live in src/harness-capabilities.ts. Each capability carries its host mechanism in the registry, so adding a key is one entry.
  • The host resolves defaults ⊕ overrides and materializes the resolved map into container.json; a reconciler in src/group-init.ts brings each group's settings.json to that state at spawn (managed keys only — operator edits and unmanaged keys are preserved).
  • workflow=off also 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), while disallowedTools schema-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 get shows raw overrides plus a resolved {state, source} view. Group-scoped agents can't change the field (same gate as cli_scope); write verbs stay admin-approval-gated.

Note: agent-teams=off is enforced by the group's settings.json, which is mounted read-write, so it is spawn-time hygiene rather than a hard in-container boundary (workflow=off keeps 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=on restores only Workflow) and ran a live main→branch upgrade confirming existing groups keep their behavior and new groups come up lean.

Stacked on #2982 — retargets to main when it merges.

🤖 Generated with Claude Code

@gabi-simons gabi-simons requested a review from gavrielc as a code owner July 8, 2026 13:40
@github-actions github-actions Bot added the core-team PR opened by a core team member label Jul 8, 2026
@gabi-simons gabi-simons changed the title feat: per-group harness capability toggles — agent-teams & workflow off by default feat: per-group harness capability toggles — lean defaults for new groups, existing grandfathered Jul 9, 2026
@gabi-simons gabi-simons force-pushed the fix/claude-allowlist-reconciliation branch from 47b1a48 to 60d1324 Compare July 9, 2026 09:36
@gabi-simons gabi-simons force-pushed the feat/harness-capabilities branch from e299afb to 3d4cb63 Compare July 9, 2026 09:36
@gabi-simons gabi-simons force-pushed the fix/claude-allowlist-reconciliation branch from 60d1324 to ad2bc65 Compare July 9, 2026 11:45
@gabi-simons gabi-simons force-pushed the feat/harness-capabilities branch from 3d4cb63 to c8178b1 Compare July 9, 2026 11:45
@gabi-simons gabi-simons force-pushed the fix/claude-allowlist-reconciliation branch from ad2bc65 to 7ce25de Compare July 12, 2026 08:47
@gabi-simons gabi-simons force-pushed the feat/harness-capabilities branch from c8178b1 to c0a4dcb Compare July 12, 2026 08:47
gabi-simons and others added 11 commits July 12, 2026 14:45
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core-team PR opened by a core team member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants