Compare commits

..

10 Commits

Author SHA1 Message Date
gavrielc 4a3e01cab8 Merge branch 'main' into feat/lean-harness-defaults 2026-07-13 16:32:28 +03:00
gavrielc b52c8c6dca Update src/group-init.settings.test.ts 2026-07-13 16:32:00 +03:00
gavrielc 69913f23ec Update CHANGELOG.md 2026-07-13 16:31:48 +03:00
gavrielc 1de7dba2b7 Update src/group-init.ts 2026-07-13 16:31:40 +03:00
gavrielc 37f60c831d Update src/group-init.ts 2026-07-13 16:31:27 +03:00
gavrielc 6bf3b3929e Update src/group-init.settings.test.ts 2026-07-13 16:31:16 +03:00
github-actions[bot] 2d3257c601 chore: bump version to 2.1.47 2026-07-13 11:24:03 +00:00
github-actions[bot] a467359d86 docs: update token count to 240k tokens · 120% of context window 2026-07-13 11:24:00 +00:00
Moshe Krupper 40d26893f8 Guard seam: one decision function for every privileged action (guarded-actions phase 2) (#2986)
* feat: guard seam — decision function, registration wrapping, grant-carrying replay, boot conformance

Every privileged action crossing the container or channel boundary now
passes one decision function — guard() in the new src/guard/ leaf —
before it executes: allow | hold | deny (hub:
engineering/requirements/guarded-actions +
engineering/discovery/guarded-actions-decisions, phase 2).

- Registration-derived catalog: registry.register() derives one entry
  per ncl command (dotted action names stamped by registerResource);
  module-edge guard.ts adapters register the domain baselines
  (agents.create, a2a.send incl. the agent_message_policies hold,
  self_mod.*, senders.admit, channels.register). The baseline is the
  decision — policy-as-data (tighten-only rules) is deferred to
  phase 3.
- All four handler registries wrap at registration: dispatch consults
  the guard; guarded delivery actions (create_agent, install_packages,
  add_mcp_server) store only the precheck → guard → handler wrapper
  (the raw handler is never stored; spec-less re-registration throws);
  response handlers + message interceptors take guard specs (channel
  registration's click + free-text name capture).
- Grant-carrying replay: approved continuations re-enter their entry
  point with the verified approval row as the grant
  (ApprovalHandlerContext.approval). A grant satisfies a hold, never a
  deny — live-row + approvalAction + grantMatches checks; the forgeable
  approved:true boolean is deleted.
- Boot conformance: the registry walk (src/guard-conformance.ts) runs
  in CI and at boot — an unmapped privileged registration stops the
  host with a banner (skill-installed code never runs this repo's CI).

Baselines are main's behavior verbatim (host trusted-caller, cli_scope
allowlist, create_agent scope branch, a2a ACL order, unconditional
self-mod hold, unknown_sender_policy, channel click auth incl. the
anchor-group approver). Sender/channel holds stay on their own tables;
guard holds map onto the existing requestApproval options
(approverUserId).

Deliberate outcome changes inherent to the replay semantics (called out
in CHANGELOG too): (a) a2a approve-then-revoke no longer delivers — the
structural baseline re-runs live on replay; (b) forged, already-consumed,
or mismatched grants refuse instead of executing; (c) the channel-name
free-text reply re-checks approver eligibility at reply time. The
D1/D2/D4 click-auth fixes are deliberately NOT here — they belong to the
approval-contract PR (D2's host fallback at dispatch replay is preserved
verbatim).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: rename src/guard/catalog.ts → guard-actions.ts

File + internal map (catalog → guardedActions). The concept stays "the
action catalog" in prose (the term the requirements/decisions docs and
the conformance banner use); exported symbols (registerGuardedAction,
GuardedActionSpec, …) unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(guard): consults carry the defined action value — delete the registry walk

The guard's weak point was the wiring: catalog entries registered by
side-effect import, consult sites naming them by string, a map miss
resolving to ALLOW, and a boot walk that could only see 2 of the 4
registries (response handlers and interceptors erased their specs into
closures). Dropping one unreferenced import silently disarmed
channel-registration click-auth.

Make the broken wiring unconstructible instead of detected:

- defineGuardedAction returns a branded GuardedAction value; guard(action,
  input) takes the value, not a name. A dropped module-edge import or a
  typo'd action is now a compile error; there is no lookup and no
  fail-open branch. A forged value (outside TS) is denied at runtime.
- Every registry (delivery actions, response handlers, interceptors)
  requires a guard spec or an explicit unguarded(<reason>) declaration at
  the registration site — unguarded-by-omission is not representable, and
  the justification travels with the registration (grep "unguarded(" for
  the complete inventory). CLI commands derive their guard inside
  register(), so a command cannot exist without one.
- guardConformanceViolations() and EXEMPT_DELIVERY_ACTIONS are gone. The
  boot check shrinks to the one cross-registry invariant the compiler
  can't see: every holding action has a registered approve continuation
  (grantContinuationGaps), same fail-closed refuse-to-start posture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(guard): one runtime discriminator for Unguarded — isUnguarded()

The unguarded brand becomes a real (module-private) symbol and
isUnguarded() its exported type guard, replacing three hand-rolled
`'reason' in guardDecl` checks (delivery, router, response-registry)
plus the one inside isUnguardedEntry. Only unguarded() can mint the
brand now, so a look-alike { reason } object — or a guard spec that
someday grows a `reason` field — can't pass as an unguarded
declaration at runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(guard): unwrap the broadcast registries — response handlers and interceptors consult inline

registerResponseHandler and registerMessageInterceptor return to main's
single-argument shape: the guard-spec/unguarded declaration machinery
(claims, onDeny, the registry wrappers) is deleted. The declaration
requirement stays where registration is keyed — every ncl command derives
its guard inside register(), and registerDeliveryAction demands a spec or
unguarded(<reason>) — while broadcast hooks gate inline at the point of
privilege, like the a2a route and the unknown-sender gate:

- handleChannelApprovalResponse consults guard(channelsRegister) inline
  where main had the click-auth if — same decision, same timing.
- The free-text name capture returns to main verbatim: the click arms it,
  the reply is not re-authorized (the body's own pending-row re-fetch
  still refuses a vanished registration). Behavior delta (c) is withdrawn
  — the deliberate deltas are back to (a) approve-then-revoke and
  (b) grant refusals.

Also gone with the wrappers: the claims/body predicate duplication (the
same early-exits existed twice and could drift apart) and the repeated
extractAndUpsertUser upsert per captured event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(guard): drop boot conformance, rename the spec vocabulary, extract runGuarded

Three trims to the seam, no behavior change (713/713 green):

- Boot-time conformance check deleted (src/guard-conformance.ts + its
  main() call). The invariant — every holding action has a registered
  approve continuation — is already covered in-tree by the conformance
  test, and at runtime response-handler.ts handles a missing continuation
  loudly (warn + "approved, but no handler is installed" notify + row
  cleanup). Refusing to boot bought no safety over that and could brick
  the host on a mis-installed skill.

- GuardedActionSpec vocabulary renamed to read as what each field does:
  baseline → decide (the decision function — allow | hold | deny),
  approvalAction → grantActionName (the pending_approvals.action string a
  grant's row must carry; "Name" because at consult sites `action` means
  the branded GuardedAction value), grantMatches → grantCoversRequest.
  Comment prose follows ("structural baseline" → the checks / the
  decision).

- runGuarded + DeliveryGuardSpec extracted to src/delivery-guard.ts.
  delivery.ts is a high-traffic file for forks: the registry itself
  (tagged entries, spec-or-unguarded registration overloads, disarm-throw,
  one-door getDeliveryAction, grant-carrying reenter) stays there, evolved
  in place — only the consult pipeline (precheck → guard → deny/hold/allow)
  and the spec type move out; runGuarded takes spec + handler explicitly so
  the new file has no import back into delivery.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: drop internal design-process references from the guard change

The public tree shouldn't point at internal artifacts: phase numbering
("guarded-actions phase 2/3") and the team-hub decisions-doc pointer are
gone from the CHANGELOG bullet, the CLAUDE.md guard row, and the guard
file headers. The facts stay ("policy-as-data is deliberately deferred —
a generalized rules table can arrive later"); only the internal roadmap
vocabulary goes. Scope: lines this branch introduced only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(guard): the name reply is not re-authorized — stop claiming it is

The channelsRegister docstring and the CLAUDE.md guard row still described
the withdrawn reply-time re-check (behavior delta (c), removed in fe235c7):
the docstring claimed the decision is consulted by the name-capture
interceptor "so a privilege revoked mid-flow is re-checked at each step",
and the CLAUDE.md row said message interceptors consult the guard inline.
Neither is true — channels.register is consulted only by the card-click
handler, and the free-text reply deliberately authorizes off the click
(main's behavior). Both docs now state that explicitly, including the
consequence: a privilege revoked between click and reply still completes
the flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(guard): an approved-then-refused a2a replay is a policy outcome, not a crash (GS-002)

Runtime testing (PR #2986 validation, finding GS-002) showed the
approve-after-revoke refusal — the PR's own deliberate behavior delta —
surfacing as "ERROR Approval handler threw" with a stack trace, and the
requester being told the apply "failed". Enforcement was correct; the
telemetry dressed an expected guard refusal as a runtime failure.

The a2a route's deny now throws a typed GuardDenyError (guard leaf), and
applyA2aMessageGate catches exactly that: the requester is notified
"Message approved, but not delivered — no longer authorized: <reason>"
and the host logs a warning. Anything else still propagates to the
response handler's crash path. Covers all three replay refusals the same
way: destination revoked while pending, mismatched grant, already-consumed
grant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: shrink the guard CHANGELOG bullets and CLAUDE.md rows to house length

The guard-seam CHANGELOG bullet was a design document; it now reads like
the neighboring entries — what changed, the two behavior deltas, done.
Same for the src/guard/ and src/delivery-guard.ts Key Files rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: gavrielc <gabicohen22@yahoo.com>
2026-07-13 14:23:45 +03:00
Gabi Simons 4f5440d2ab feat: lean harness defaults for new agent groups
New groups' settings.json no longer enables Claude Code's experimental
agent-teams feature and sets disableWorkflows: both overlap NanoClaw's
own systems (a2a messaging; host-side orchestration), and together they
are ~20% of every turn's context (live-measured: ~38,900 -> ~30,300
tokens on a fresh session). DesignSync and ReportFindings — desktop/UI
tools that cannot function headless — join the fixed disallow list.

Existing groups are untouched: their settings.json already exists and
is only ever patched additively, never regenerated. Re-enabling either
feature for one group is a one-file edit to that group's settings.json
plus a group restart — and it sticks, because nothing reconciles the
file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 09:52:13 +03:00
5 changed files with 79 additions and 6 deletions
@@ -24,6 +24,10 @@ function log(msg: string): void {
// the question and blocks on the real reply.
// - EnterPlanMode / ExitPlanMode / EnterWorktree / ExitWorktree: Claude
// Code UI affordances; in a headless container they'd appear stuck.
// - DesignSync: desktop design-tool integration — nothing to sync with in a
// headless container (~9.3KB/turn schema).
// - ReportFindings: code-review-reporting UI affordance with no headless
// host surface to receive it (~1.9KB/turn schema).
const SDK_DISALLOWED_TOOLS = [
'CronCreate',
'CronDelete',
@@ -34,6 +38,8 @@ const SDK_DISALLOWED_TOOLS = [
'ExitPlanMode',
'EnterWorktree',
'ExitWorktree',
'DesignSync',
'ReportFindings',
];
// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.46",
"version": "2.1.47",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="232k tokens, 116% of context window">
<title>232k tokens, 116% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="240k tokens, 120% of context window">
<title>240k tokens, 120% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">232k</text>
<text x="71" y="14">232k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">240k</text>
<text x="71" y="14">240k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+68
View File
@@ -0,0 +1,68 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-group-init-settings-test';
vi.mock('./config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config.js')>()),
DATA_DIR: '/tmp/nanoclaw-group-init-settings-test/data',
GROUPS_DIR: '/tmp/nanoclaw-group-init-settings-test/groups',
}));
vi.mock('./log.js', () => ({
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
}));
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { initGroupFilesystem } from './group-init.js';
import type { AgentGroup } from './types.js';
function makeGroup(id: string): AgentGroup {
const ag = { id, name: id, folder: id, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
createAgentGroup(ag);
return ag;
}
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('default settings.json for new groups', () => {
it('is lean: no agent-teams env key, unmanaged keys intact', () => {
const ag = makeGroup('ag-lean');
initGroupFilesystem(ag, {});
const file = path.join(TEST_ROOT, 'data', 'v2-sessions', ag.id, '.claude-shared', 'settings.json');
const settings = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(settings.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined();
expect(settings.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD).toBe('1');
expect(JSON.stringify(settings.hooks.PreCompact)).toContain('compact-instructions');
});
it('never rewrites an existing settings.json — a hand-edited re-enable sticks', () => {
const ag = makeGroup('ag-reenable');
initGroupFilesystem(ag, {});
const file = path.join(TEST_ROOT, 'data', 'v2-sessions', ag.id, '.claude-shared', 'settings.json');
// Operator re-enables both features by editing the file (the documented path).
const edited = JSON.parse(fs.readFileSync(file, 'utf-8'));
delete edited.disableWorkflows;
edited.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
fs.writeFileSync(file, JSON.stringify(edited, null, 2) + '\n');
initGroupFilesystem(ag, {}); // next spawn
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(after.disableWorkflows).toBeUndefined();
expect(after.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1');
});
});
-1
View File
@@ -11,7 +11,6 @@ const DEFAULT_SETTINGS_JSON =
JSON.stringify(
{
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},