Skip to content

fix(security): use CSPRNG for approval card ids + clicker authorization#2545

Open
smith-vosburg wants to merge 2 commits into
nanocoai:mainfrom
vosburg-auto:security/csprng-approval-ids
Open

fix(security): use CSPRNG for approval card ids + clicker authorization#2545
smith-vosburg wants to merge 2 commits into
nanocoai:mainfrom
vosburg-auto:security/csprng-approval-ids

Conversation

@smith-vosburg
Copy link
Copy Markdown

@smith-vosburg smith-vosburg commented May 18, 2026

Type of Change

  • Fix - bug fix or security fix to source code

Description

Two related security fixes to the approval-card flow.

1. Replace Math.random() approval ids with crypto.randomBytes()

shortApprovalId() in src/modules/approvals/onecli-approvals.ts currently does:

Math.random().toString(36).slice(2, 10)  // ~41 bits of entropy

This id is the secret for click approvals. The Chat SDK's Telegram adapter uses it directly as callback_data actionId, and resolveOneCLIApproval() dispatches the registered approval handler when a click arrives carrying that id. There is no second-factor auth on the click path beyond knowing the id, so anyone who can submit a callback with a guessed id can approve a credentialed action they shouldn't.

~41 bits of Math.random() entropy (which is not a CSPRNG to begin with — V8's Math.random is xorshift128+) is well within brute-force range against the pool of pending approvals.

Fix: randomBytes(16).toString('base64url') → 128 bits of CSPRNG entropy. The encoded id is 22 chars, comfortably under Telegram's 64-byte callback_data budget after the oa- prefix and the Chat SDK callback wrapper.

2. Add a clicker-authorization check in the approval response handler

Independently of the id-guessing risk, the response handler in src/modules/approvals/response-handler.ts did not verify that the clicker is one of the eligible approvers for the approval's agent group. Without this check, a forged click — including one that spoofs another user's id via payload.userId — would dispatch the approval handler.

Fix: new isAuthorizedClicker() runs pickApprover(session.agent_group_id).includes(clickerId) (where clickerId = ${channelType}:${userId}) before invoking the handler. Mirrors the existing handleSenderApprovalResponse check in src/modules/permissions/. Unauthorized clicks are claimed (so the dispatcher doesn't retry) but no other action is taken; the pending row stays so a real approver can still resolve it.

Threat model

The id-IS-secret property means either of these alone is exploitable:

  • An attacker who can enumerate/guess pending approval ids (~41 bits) and forge a click → approval handler fires.
  • An attacker who can submit a click with a known pending id but a spoofed userId → approval handler fires (no auth check).

Both fixes are needed for defense-in-depth: CSPRNG ids make the first attack infeasible; clicker auth makes the second attack ineffective even if an id leaks (e.g. via logs, screen-share, debug output).

Tests

  • src/modules/approvals/onecli-approvals.test.ts (new) — id format + entropy properties
  • src/modules/approvals/response-handler.test.ts (new) — auth-gate end-to-end behavior
Test Files  2 passed (2)
     Tests  7 passed (7)

pnpm typecheck is clean. Lint only reports pre-existing no-catch-all warnings on the touched files (not introduced by this PR).

Notes

  • Cherry-picked from vosburg-auto/nanoclaw@ad4d5f5. The original commit also bundled a src/webhook-server.test.ts for a resolveListenConfig export that doesn't exist on this repo's main — that test was dropped from this PR. If you want the companion change (default webhook bind to 127.0.0.1 instead of the current 0.0.0.0 LAN-exposed bind in src/webhook-server.ts:121), happy to open a separate PR.
  • Authored by ark234 (commit author preserved through the cherry-pick); pushed via the smith-vosburg machine identity from the same operator.

🤖 Generated with Claude Code

Two security improvements with tests:

1. shortApprovalId now uses crypto.randomBytes(16).toString('base64url')
   (128 bits) instead of Math.random().toString(36).slice(2,10)
   (~41 bits, brute-forceable). The id IS the secret — anyone who can
   guess a pending id can approve a credentialed action. New version
   stays inside Telegram's 64-byte callback_data budget. Exported now
   so tests can probe entropy + character set.

2. handleApprovalsResponse re-verifies the clicker is in the eligible-
   approvers list for the approval's agent group, via a new
   isAuthorizedClicker check that compares pickApprover() against the
   namespaced clickerId (${channelType}:${userId}). Without this,
   forged clicks — including userId-spoofed ones — would dispatch the
   handler. Mirrors handleSenderApprovalResponse in permissions/.

Adds tests:
- onecli-approvals.test.ts (41) — id format + entropy
- response-handler.test.ts (193) — auth-gate behavior end-to-end
- webhook-server.test.ts (23) — resolveListenConfig defaults
  loopback so the webhook port isn't LAN-exposed

Note: main has not touched onecli-approvals.ts or response-handler.ts
since the WIP base (a4346f5), so no upstream merge concerns.
Worth opening a PR upstream after the SSD flash — these are genuine
security fixes.
@github-actions github-actions Bot added follows-guidelines PR was created using the current contributing template PR: Fix Bug fix labels May 18, 2026
smith-vosburg added a commit to vosburg-auto/nanoclaw that referenced this pull request May 19, 2026
…en estimator

Two follow-ups to the panel review on #1.

1. src/modules/approvals/primitive.ts: bump module-initiated approval ids
   from `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` (~30
   bits of non-CSPRNG entropy) to `appr-${randomBytes(16).toString('base64url')}`
   (128 bits CSPRNG). Same shape as nanocoai#2545's shortApprovalId fix for the
   OneCLI path; this is the parallel fix for the requestApproval()-initiated
   path (self-mod install_packages, add_mcp_server, future module gates).
   The new isAuthorizedClicker gate is the load-bearing defense, but make
   the id itself unguessable so a hypothetical bypass of the auth check
   doesn't fall back on a 30-bit secret.

2. container/skills/context-awareness/SKILL.md: drop the
   `ls ~/.claude/projects/*/session-*.jsonl` token-estimator block. That
   path is host-side Claude Code state; inside Smith's container, sessions
   live in /workspace/{inbound,outbound}.db SQLite files, so the script
   always printed "No session file found". Replaced with prose explaining
   the actual session model and how to estimate from in-turn activity.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
ark234 added a commit to vosburg-auto/nanoclaw that referenced this pull request May 19, 2026
…atches (#1)

* docs: add fork-specific notes in FORK.md

* feat(cli): scaffold `nc` CLI with `list-groups` command

Adds a transport-agnostic CLI control plane shared between three eventual
callers (host shell, Claude in project, container agent) — though only the
host-side socket transport is wired in this commit. Container DB transport
and approval flow land alongside the first risky command.

- src/cli/frame.ts:        wire format (RequestFrame, ResponseFrame, CallerContext)
- src/cli/registry.ts:     command registry with RiskClass
- src/cli/dispatch.ts:     transport-agnostic dispatcher
- src/cli/transport.ts:    Transport interface
- src/cli/socket-client.ts: SocketTransport against data/nc.sock
- src/cli/socket-server.ts: host-side listener (chmod 0600, line-delimited JSON)
- src/cli/format.ts:       human table / --json output modes
- src/cli/client.ts:       `nc` argv -> frame -> transport -> stdout
- src/cli/commands/list-groups.ts: first command (riskClass: safe)
- bin/nc:                  bash launcher (resolves project root via symlink)
- src/index.ts:            start/stop server + import command barrel

`data/nc.sock` is intentionally separate from `data/cli.sock` (which the
existing chat-style channel adapter still owns).

Verified end-to-end: `nc list-groups`, `nc list groups`, `--json`,
unknown-command error, host-down ENOENT message with start instructions.
typecheck clean; eslint reports only the same `no-catch-all` warnings the
rest of the codebase has.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* style(cli): apply prettier formatting

Pre-commit hook ran prettier on the prior commit but left the reformats
unstaged. Folding them in here so the branch is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(cli): wire nc CLI commands into container agent

Add delivery action handler (cli_request) so the host dispatches CLI
commands arriving from container agents via outbound.db and writes
responses back to inbound.db. Add nc MCP tool in the agent-runner
following the ask_user_question blocking pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(cli): replace MCP tool with standalone nc client in container

Drop the nc MCP tool in favor of a standalone Bun CLI script at
container/agent-runner/src/cli/nc.ts. Same interface as host-side
bin/nc — all three callers (operator, Claude on host, agent in
container) now use the same nc CLI.

Container transport: writes cli_request to outbound.db (BEGIN
IMMEDIATE for seq safety), polls inbound.db for response, acks via
processing_ack. Dockerfile adds a /usr/local/bin/nc wrapper that
execs the mounted source.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(cli): add CRUD helper, resource definitions, and help command

Resource-first CLI: `nc groups list`, `nc wirings get <id>`, etc.
Seven resources defined (groups, messaging-groups, wirings, users,
roles, members, sessions) with full column documentation that serves
as the single source of truth for help output and arg validation.

- CRUD helper auto-registers list/get/create/update/delete from
  declarative resource definitions with generic SQL
- Custom operations for composite-PK resources (roles grant/revoke,
  members add/remove)
- Access model: open (reads) / approval (writes) / hidden
- `nc help` lists resources; `nc <resource> help` shows fields
- Positional target IDs: `nc groups get <id>`
- Removed unused priority column from wirings

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(cli): add remaining resources, fix descriptions from code review

New read-only resources:
- destinations (agent-to-agent ACL + routing map)
- user-dms (DM channel cache)
- dropped-messages (audit trail for dropped messages)
- approvals (in-flight approval cards)

Description fixes from reading source:
- messaging-groups: add denied_at column (router checks it)
- sessions: fix container_status (idle is unused, stopped is
  auto-restarted by sweep)
- wirings: add note that threaded adapters force per-thread

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style(cli): apply prettier formatting

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* setup: tidy Slack app-creation card

- Move the "Get started: …" URL above the numbered instructions and
  render it in bright white so it pops against the brand-cyan body.
  (Headless-only — interactive runs still auto-open the URL in a
  browser, no card line.)
- Group the OAuth scope list vertically by family (im, channels,
  groups, chat, users, reactions) instead of one comma-run wall.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* setup: correct Slack member-ID card directions

Slack's profile button is in the bottom-left of the desktop sidebar (not
the top-right), and the "More" overflow icon next to "Copy member ID" is
the vertical kebab `⋮`, not the horizontal `⋯`. Match what users actually
see in Slack.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* setup: add back-to-channels exit at every Teams step gate

Teams setup is 6+ Azure steps over 30+ minutes. Today, every
"Done / Stuck / Show again" gate forces continuation; the only escape
is Ctrl-C, which kills setup entirely. Add a fourth option at each gate
that returns to the channel picker so a stuck operator can pick a
different channel without losing the rest of setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* setup: add back-to-channels exit to "Other…" channel-name prompt

After picking "Other…" from the channel picker, today's flow drops the
user straight into a free-text prompt with no way back. Replace it with
a brightSelect that offers either "Type the channel name" (existing
behavior) or "← Back to channel selection" — same back-affording pattern
the channel sub-flows already use.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* setup: drop "E.164" jargon from iMessage handle card

Replace "full E.164, e.g. +15551234567" with plain-language guidance
mirroring the WhatsApp setup card: "start with + and your country code,
no spaces or dashes" plus a worked example. "E.164" is the technical
name for the format and means nothing to non-telecom users; the
explanation it stands in for is one sentence.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(skills): add /add-mnemon skill — persistent semantic memory for agent groups

Adds a skill that installs the mnemon CLI into agent containers, giving each
agent group a persistent, queryable knowledge graph across sessions.

Mnemon stores facts (insights) with categories, importance scores, and entity
tags, and connects them with typed edges (causal, semantic, temporal, entity).
The agent can remember, recall, search, link, and forget facts — surviving
container restarts and context compaction.

Installation: drops the mnemon binary from the channels branch, creates the
per-agent-group data directory, and configures the agent's CLAUDE.md to load
the skill on every spawn.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* docs(skills): update SKILL.md for debug, init-onecli, add-gmail-tool, add-opencode, add-signal, add-vercel

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: slim credential docs in group CLAUDE.md and add onecli-gateway container skill

* fix(add-karpathy-llm-wiki): update for v2 — schedule_task MCP + no build step

* setup: add "Skip — I'll connect later" option to Claude auth picker

Today the Claude auth picker has only three real-auth options. A user
without a Pro/Max subscription, an OAuth token, or an API key has no
graceful escape — Ctrl-C kills setup entirely.

Add a fourth option that confirms the trade-off (no agent runtime + no
Claude debug help during setup) and, on Yes, marks auth skipped and
lets setup continue. On No, loop back to the picker. Existing
NANOCLAW_SKIP=auth env hatch is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(destinations): default to replying to the origin destination

When a multi-destination agent receives an inbound message, the model
had no explicit guidance about which destination to address by default
and would sometimes pick the wrong one — e.g. Casa replying to the
admin's group questions in Laura's DM instead of in the group itself.

The formatter already injects `from="<destname>"` on every inbound
<message> tag (formatter.ts:184), so the origin is right there in the
prompt — the system prompt just never told the agent to use it.

Added one line to buildDestinationsSection() that nudges the agent
toward replying via the same destination the message came from, with
an out for explicit cross-destination requests ("tell Laura that…").

Single-destination groups are unaffected (they take a separate
short-circuit path with a fallback that auto-replies to the origin).

Tests cover the multi-destination, single-destination, and
no-destination cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat(poll-loop): inject destination reminder after SDK auto-compaction

Closes qwibitai/nanoclaw#2325.

When the Claude Code SDK auto-compacts the conversation context, the
compaction summary tends to drop the agent's learned <message to="…">
wrapping discipline. The destinations table is still populated and the
system prompt still lists them, but the behavioral pattern degrades —
A2A sends and multi-channel routing silently revert to bare-text or
single-channel delivery for the rest of the session, until the next
/clear.

Three small changes wire a reminder back into the live query when this
fires:

- New `compacted` event on ProviderEvent. Distinct from `result` so it
  doesn't mark the turn completed or get dispatched as a chat message
  (which is also why "Context compacted (N tokens compacted)." stops
  appearing as noise in user-facing chats — it was a side-effect of
  reusing the result event path).
- ClaudeProvider yields `compacted` instead of `result` for the SDK's
  compact_boundary system event.
- Poll-loop's event handler reacts by pushing a system-tagged reminder
  back into the active query when there are >1 destinations. Single-
  destination groups skip the push since they have a fallback that
  works without wrapping.

Tests cover both branches (multi-destination → reminder fires;
single-destination → no reminder) using a CompactingProvider that
emits the new event mid-stream.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* Apply suggestion from @gavrielc

* fix(agent-runner): require explicit destination addressing, fix per-destination threading

The poll loop had a bare-text routing fallback in dispatchResultText: when
the agent produced text without <message to="..."> wrapping, it would auto-
route to the session's originating channel (via a frozen RoutingContext) or
to the single configured destination. This caused three problems:

1. Routing drift: RoutingContext was extracted once from the initial batch
   and never refreshed. When the initial batch was a null-routed cron task
   and a real chat arrived mid-query, replies were silently dropped to
   scratchpad because the frozen routing had all-null fields.

2. Cross-channel thread bleed: sendToDestination applied a single
   routing.threadId to every outbound message regardless of destination.
   In agent-shared sessions (multiple channels sharing one session), one
   channel's thread ID was stamped onto messages to a different channel.

3. Inconsistent formatting: task, webhook, and system messages had no
   origin metadata in their formatted output, so the agent couldn't tell
   which destination they came from — even when the underlying messages_in
   rows carried routing fields.

Changes:

- Remove the bare-text routing fallbacks in dispatchResultText (both the
  routing-based and single-destination shortcuts). All agent output must
  be wrapped in <message to="name">...</message>. Bare text is scratchpad.

- Update buildDestinationsSection() to require explicit wrapping for all
  groups, including single-destination. No more "no special wrapping
  needed" shortcut.

- Resolve thread_id per-destination via resolveDestinationThread(), which
  queries messages_in for the most recent message matching the target
  channel+platform. Falls back to null (top-level channel message) when
  no prior inbound exists for that destination.

- Extract originAttr() helper in formatter.ts and apply it to all message
  types. Tasks now render as <task from="dest" time="...">, webhooks as
  <webhook from="dest" source="..." event="...">, system responses as
  <system_response from="dest" ...>. The agent always sees where a
  message originated.

- Add a PreCompact shell hook (compact-instructions.ts) that outputs
  custom compaction instructions, telling the compactor to preserve
  recent message XML structure and routing metadata in the summary.
  Wired via settings.json in the .claude-shared scaffold, with a
  migration path (ensurePreCompactHook) for existing groups.

Relation to open PRs:

- #2277 (mergeRouting) becomes unnecessary — the routing fallback it
  patches no longer exists. Can be closed.
- #2327 (post-compaction destination reminder) is complementary — it
  handles the post-compaction push, this handles pre-compaction
  instructions. Both can merge independently.
- #2328 (default routing instruction) is complementary — it adds "reply
  to the from= destination" guidance to the multi-destination section.
  Compatible with the unified instruction format here.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* address review: add thread resolution test, log catch, remove stray comment

- Add integration test for per-destination thread_id resolution: seeds two
  destinations with different thread IDs, verifies each outbound message
  carries the correct thread_id (not a global one from the batch routing).
- Add log line in resolveDestinationThread catch block for debuggability.
- Remove stray "(ensurePreCompactHook is defined after the main function.)"
  comment from group-init.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.34

* docs: update token count to 142k tokens · 71% of context window

* fix(channels): exponential backoff for gateway listener restarts

Without this, an unrecoverable failure such as TokenInvalid causes the
gateway listener to restart ~10x/sec, which Discord's Cloudflare layer
treats as abuse and answers with a multi-hour IP block. Both the clean-
expiry path and the error path now share a backoff that doubles up to
1h, with a >5min healthy run resetting the counter.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* feat: fetch gateway skill from OneCLI API with static fallback

* chore: bump version to 2.0.35

* docs: update token count to 143k tokens · 71% of context window

* fix(container): pin pnpm to 10.33.0 to match host

Corepack with no version pin pulls latest pnpm (currently 11.0.8), which
silently stops honoring `only-built-dependencies[]=` in `.npmrc` for
global installs. The allowlist file ends up correctly written but
ignored, so:

  - `@anthropic-ai/claude-code`'s postinstall — which downloads the
    platform-native Claude binary — never runs. Agents then crash at
    runtime with "claude native binary not installed... postinstall did
    not run."
  - `agent-browser`'s postinstall, which chmods the linux-arm64 binary,
    is also skipped, so the binary fails with EPERM the first time it's
    invoked.

Pin the container's pnpm to 10.33.0 (the same version host's
package.json already pins via `packageManager`). Keep the two in
lockstep so a host bump triggers a deliberate container bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore: bump version to 2.0.36

* chore: bump version to 2.0.37

* docs: update token count to 144k tokens · 72% of context window

* Apply suggestion from @gavrielc

* docs: update token count to 145k tokens · 72% of context window

* chore: bump version to 2.0.38

* test(agent-runner): add dispatch, origin metadata, and thread resolution tests

Add 14 tests covering key routing and dispatch flows that previously had
zero direct coverage:

dispatchResultText:
- bare text produces no outbound (scratchpad only)
- unknown destination dropped, valid destination sent
- multiple <message> blocks each produce correct outbound
- internal tags stripped from scratchpad

originAttr / from= metadata:
- chat/task/webhook/system messages include from= when destination matches
- fallback to raw unknown:channel:platform when no match
- from= omitted when routing is null

resolveDestinationThread:
- null thread_id when no prior inbound from destination
- most recent thread_id wins with multiple inbound messages

Also fix merge issue: restore getAllDestinations import removed by our PR
but still needed by #2327's compaction reminder. Fix stale destinations
test assertion from #2328 ("no special wrapping needed" → "Every response
must be wrapped").

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: update token count to 147k tokens · 73% of context window

* chore: bump version to 2.0.39

* test: add host-side routing and session resolution tests

Host-side (vitest):
- Routed message preserves platformId/channelType/threadId on messages_in
- Fan-out gives each agent correct per-agent routing
- writeSessionRouting populates session_routing from messaging group
- writeSessionRouting writes null routing for agent-shared sessions
- Per-thread session includes thread_id in session_routing
- Agent-shared resolves to same session on repeated calls
- Agent-shared session has null messaging_group_id
- findSessionByAgentGroup returns channel-bound session (documents #2332)
- Skip: agent-shared/channel-bound coexistence (blocked on #2332 fix)

Container-side (bun:test):
- Internal tags stripped between message blocks
- Mixed task + chat batch with correct routing

The agent-shared tests uncovered the exact bug from #2332:
findSessionByAgentGroup doesn't distinguish agent-shared from
channel-bound sessions, so A2A resolution reuses a channel session
when one exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: update token count to 147k tokens · 74% of context window

* test(a2a): add tests documenting A2A routing bugs (#2332)

Three tests that exercise agent-to-agent routing and document the broken
behavior that #2332 describes:

1. A2A outbound lands in target session — basic happy path, passes.

2. A2A return path resolves to wrong session when source agent has
   multiple channel sessions. Researcher responds to PA, but
   findSessionByAgentGroup picks PA's newest session (Discord) instead
   of the Slack session that originated the A2A call. Test asserts the
   buggy behavior (response in Discord, nothing in Slack).

3. A2A-only session gets null session_routing. writeSessionRouting on a
   session with messaging_group_id=NULL writes all nulls — the target
   agent has no default routing for replies. Test asserts the nulls.

These tests pass today by asserting the broken state. When #2332 is
fixed (origin-aware return routing), these assertions should flip to
the correct behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.40

* fix(agent-to-agent): route A2A replies back to originating session (#2267)

Squash merge of PR #2267 by ddaniels.

When an agent group has more than one active session, A2A replies landed
in the newest session via findSessionByAgentGroup's ORDER BY created_at
DESC. The session that asked the question never saw the answer.

Adds origin-aware return-path routing with three layers:

1. Direct return-path: if the reply has in_reply_to, look up the
   triggering inbound row's source_session_id and route there.
2. Peer-affinity fallback: find the most recent A2A inbound from this
   peer and use its source_session_id.
3. Legacy fallback: newest active session (pre-migration compat).

Container-side: MCP send_message/send_file now thread the current
batch's in_reply_to through to outbound rows via current-batch.ts.

Also flips our A2A bug-documenting test (#2332) from asserting the
broken behavior to asserting the fixed behavior.

Co-Authored-By: Doug Daniels <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: update token count to 149k tokens · 75% of context window

* chore: bump version to 2.0.41

* docs(cli): add agent instructions for nc CLI

Auto-discovered by composeGroupClaudeMd() as module-cli.md fragment,
included in every agent group's composed CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test: remove stale A2A session coexistence tests

The skipped coexistence test and the findSessionByAgentGroup
bug-documenting test were written before the A2A return-path fix
(#2267). That fix sidesteps findSessionByAgentGroup entirely —
A2A replies now use source_session_id for routing, so the
"newest session wins" behavior is only a fallback for unsolicited
first-contact A2A where any session will do.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.42

* refactor: use static gateway skill instead of fetching on spawn

Remove the dynamic `onecli.getGatewaySkill()` fetch from `buildMounts` —
the skill content ships as a static SKILL.md. This avoids adding latency
to every container spawn and dirtying the source tree at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.43

* test(agent-to-agent): add missing routing coverage

- Stale origin fallback (archived session falls through to newest)
- Cross-agent-group guard (origin from wrong group rejected)
- Non-a2a in_reply_to (channel message ref falls through)
- Self-message bypass (no destination row needed)
- File forwarding (bytes copied from outbox to inbox)
- Unbounded ping-pong documenting #2063 loop gap

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.44

* test: add delivery retry, permission check, and poll-loop error recovery coverage

Delivery:
- Retry exhaustion: adapter fails 3x → markDeliveryFailed
- Retry recovery: transient failure then success clears counter
- Permission check: unauthorized channel destination blocked

Poll-loop (container):
- Provider error: error written to outbound, loop continues
- Stale session: isSessionInvalid → continuation cleared
- /clear command: session wiped, confirmation written

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: update token count to 150k tokens · 75% of context window

* fix(tests): add missing in_reply_to fields, correct session status type

- host-core.test.ts: add in_reply_to: null to routeAgentMessage calls
  (required after #2267 added the field to RoutableAgentMessage)
- agent-route.test.ts: use 'closed' instead of 'archived' (not a valid
  Session status)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.45

* feat(setup): default to interactive Claude handoff on failure

Failures now launch an interactive Claude session instead of the
non-interactive assist (REASON/COMMAND parser). The user debugs
with full terminal access and types /exit to return to setup.

The original assist mode is available via --assist-mode flag or
NANOCLAW_SETUP_ASSIST_MODE=1 env var.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(cli): rename nc to ncl

Rename the CLI binary, socket path, container wrapper, error prefixes,
and all references from `nc` to `ncl`. Add ~/.local/bin symlink during
setup and pnpm script alias.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(cli): wire approval flow for agent CLI commands

When a container agent calls an approval-gated ncl command, dispatch
now sends an approval card to an admin instead of returning a stub
error. On approve, the handler re-dispatches the original command
and notifies the agent with the result.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs(cli): add write examples, approval flow, and nc→ncl rename

- Add approval flow section explaining the request→notify→result mechanics
- Add write command examples (groups create, roles grant, members add, etc.)
- Rename stale `nc` references to `ncl` in container instructions
- Add CLI reference section to host CLAUDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(cli): add list filtering/pagination, fix double-close in container ncl

- genericList now accepts column filters (--flag value) and LIMIT (default 200)
- Remove early inDb.close() in container pollResponse to avoid double-close
- Document filtering and --limit in cli.instructions.md

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: fix prettier formatting

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.46

* docs: update token count to 166k tokens · 83% of context window

* docs: add ncl CLI to changelog

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add v2.0.45 changelog entry

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: remove migration fixes from changelog

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(db): move container config from filesystem to DB

Source of truth for container runtime config moves from
groups/<folder>/container.json to a new container_configs table.
The file becomes a materialized view written at spawn time.

- New container_configs table with scalar columns (provider, model,
  effort, image_tag, assistant_name, max_messages_per_prompt) and
  JSON columns (mcp_servers, packages_apt, packages_npm, skills,
  additional_mounts)
- Startup backfill seeds DB from existing container.json files
- materializeContainerJson() replaces readContainerConfig + ensureRuntimeFields
- Self-mod handlers (install_packages, add_mcp_server) write to DB
- Provider cascade simplified: session -> container_configs -> 'claude'
- ncl groups config-{get,update,add-mcp-server,remove-mcp-server,
  add-package,remove-package} custom operations
- restartAgentGroupContainers() helper for config change propagation
- Container side unchanged (still reads /workspace/agent/container.json)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: harden container config DB layer

- config-add/remove-package now rebuild image + restart containers
- Deduplicate packages in self-mod install_packages handler
- Add runtime whitelist guards for SQL column interpolation

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: move column whitelist consts to module top

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(container-runner): raise install_packages build timeout to 15min

The 5-minute timeout in buildAgentGroupImage was tight for first-time
apt + pnpm global installs on slow networks (the exact scenario
install_packages triggers, since the image hasn't pre-installed the
requested packages). Hit ETIMEDOUT on a real install with apt + npm
packages.

900_000ms gives realistic headroom without masking genuinely hung builds.

* feat(cli): support space-separated multi-word verbs

`ncl groups config get` now works alongside `ncl groups config-get`.
Parser joins all positionals with dashes; dispatcher falls back by
trimming the last segment as a target ID (`ncl groups get abc123`).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(cli): use spaces in custom operation keys

Operation keys like 'config get' read naturally and crud.ts normalizes
spaces to dashes for the registry name.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(cli): remove deprecated agent_provider from groups columns

Provider is now managed via `ncl groups config update --provider`.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(cli): decouple package commands from docker build

config add/remove-package should only update the DB and restart.
Image rebuild is handled by the self-mod approval flow or manually.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.47

* feat: race-free on-wake messages and explicit restart CLI

Decouple container restart from config updates — config CLI ops now only
write to the DB; restart is a separate `ncl groups restart` command with
--rebuild and --message flags. Add on_wake column to messages_in so wake
messages are only picked up by a fresh container's first poll, preventing
dying containers from stealing them during the SIGTERM grace window.
killContainer accepts an onExit callback for race-free respawn. Agent-
called restart auto-scopes to the calling session.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: per-group CLI scope (disabled/group/global)

Add cli_scope column to container_configs with three levels:
- disabled: agent never learns about ncl (instructions excluded from
  CLAUDE.md) and host dispatch rejects any cli_request
- group (default): agent can only access groups, sessions, destinations,
  and members resources, scoped to its own agent group with auto-filled
  --id/--agent_group_id/--group args. Help output reflects the scope.
- global: unrestricted access (current behavior)

Enforcement is host-side only — no image rebuild or env var needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat: default owner agent group to global CLI scope

When init-first-agent creates an agent group for an owner, set
cli_scope to 'global' so the owner's personal agent has full ncl
access. All other agent groups remain 'group'-scoped by default.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(security): block cli_scope escalation and cross-group data leaks

Group-scoped agents could previously:
- See all agent groups via `groups list` (generic list skips --id filter)
- Look up any session by UUID via `sessions get`
- Request cli_scope change to global via config update approval

Fixed by:
- Post-handler filtering: list results filtered, get results verified
  against caller's agent_group_id
- Pre-handler --id check scoped to resources where id IS the group ID
  (groups, destinations) so session UUIDs aren't falsely rejected
- cli_scope/cli-scope args blocked outright for group-scoped agents,
  before the approval gate

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: update for container config DB, on-wake, and CLI scope

- CLAUDE.md: new key files, updated groups verbs, rewritten self-mod
  section, new Container Config and Container Restart sections
- db-central.md: container_configs table (§1.15), migrations 014+015
- db-session.md: messages_in schema with trigger, source_session_id,
  on_wake columns
- schema.ts: comment no longer references disk-based config
- cli.instructions.md: rewritten for scope-aware usage, auto-fill,
  restart/config ops, group-scoped examples

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: fix prettier formatting

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.48

* docs: update token count to 174k tokens · 87% of context window

* fix: re-stage prettier-formatted files in pre-commit hook

The hook ran format:fix but didn't re-stage the modified files, so
commits went through with unformatted code and CI caught the diff.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: only re-stage previously staged files in pre-commit hook

Capture staged file list before prettier runs, then re-add only
those files. Prevents pulling in unrelated unstaged changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add changelog entries for container config DB, on-wake, CLI scope

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: move changelog entries to 2.0.48

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: remove empty Unreleased section from changelog

* docs: explain that CLI config changes require restart

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.49

* docs: move restart guidance into config help descriptions

One-liner in cli.instructions.md pointing to `ncl groups config help`.
Each config operation's description now says whether restart or rebuild
is needed — agent discovers it via progressive disclosure.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.50

* docs: clarify --message flag on restart for config help

Explain that --message sets an on-wake instruction so the fresh
container can continue after restart (verify tools, notify user).
Without it, the container only comes back on the next user message.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.51

* docs: soften restart description wording

* chore: bump version to 2.0.52

* feat(container-config): add per-group model + effort overrides

Allow individual agent groups to opt into different models or effort levels
without changing host-wide defaults. Useful when one group is high-stakes
(opus, high effort) but most are routine (sonnet/haiku, low effort).

container.json gains two optional fields:
  - model: alias ("sonnet" | "opus" | "haiku") or full model ID
  - effort: "low" | "medium" | "high" | "xhigh" | "max"

Both omitted = SDK default (current behavior). The host plumbs them as
NANOCLAW_MODEL / NANOCLAW_EFFORT env vars at container spawn time; the
agent-runner reads them in providers/index.ts and threads through to the
provider via ProviderOptions. The Claude provider passes them straight to
sdkQuery options.

`effort` is currently typed as `any` because the @anthropic-ai/claude-
agent-sdk type doesn't surface it yet — passing it through still works at
runtime via the SDK's loose option handling. Drop the cast once the SDK
adds an `effort` field to its options type.

* chore: bump version to 2.0.53

* chore(container): bump claude-code 2.1.116 → 2.1.128

12 patch versions ahead. The 2.1.120 binary baseline introduced a
number of plugin and skill behaviors that have since landed in the
public Claude Code docs: ${CLAUDE_EFFORT} substitution, settled
`arguments` field in skill frontmatter, plugin `channels` field.

No breaking changes for nanoclaw's runtime contract. Verified by
running container/skills/{agent-browser,vercel-cli,slack-formatting}
under the bumped image; all three load and execute as expected.
SDK at ^0.2.116 (caret) remains compatible with claude-code 2.1.128.

Bumping CLAUDE_CODE_VERSION invalidates the pnpm install layer in
container/Dockerfile and triggers a full rebuild of the agent image.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* chore: bump version to 2.0.54

* docs: add changelog entry for 2.0.54

* fix: teach agent to use OneCLI gateway credentials after MCP server install

* fix(cli-scope): add scopeField to ResourceDef for fail-closed group scope

* fix(cli-scope): fail-closed scopeField enforcement and sessions-get oracle guard

* fix(cli-scope): add scopeField to groups, sessions, destinations, members

* fix(cli-scope): add scopeField to groups, sessions, destinations, members

* fix(cli-scope): add scopeField to groups, sessions, destinations, members

* fix(cli-scope): add scopeField to groups, sessions, destinations, members

* fixup(cli-scope): build error, false-positive on custom ops, tests, drop FORK.md

Addresses review feedback on this branch:

- Fix TS2352 build error in dispatch.ts: `getSession()` returns `Session`,
  which has no index signature, so `(s as Record<string, unknown>)` is rejected
  by tsc. `Session.agent_group_id` exists — read it directly.

- Fix a regression introduced by dropping the `groupField in data` guard:
  the post-handler scope check now runs for *every* command under a whitelisted
  resource, including custom ops, which return ad-hoc shapes. `ncl groups config
  get` (access:open, reachable by a group-scoped agent) returns a config object
  with no `id` field → `data['id'] !== ctx.agentGroupId` → `forbidden`, even on
  the agent's own config. Fix: tag the auto-generated list/get handlers with
  `generic: 'list' | 'get'` on `CommandDef` (set in `registerResource`) and run
  the post-handler check only when `cmd.generic` is set. Generic handlers return
  raw DB rows that carry `scopeField`; custom ops are already pinned to the
  caller's group by the pre-handler `--id` auto-fill or the approval gate.
  Fail-closed-when-`scopeField`-missing is preserved (now scoped to generic
  list/get).

- Tests: `dispatch.test.ts` mocks `getResource` (the real resources aren't
  registered in this unit), tags the two post-handler test commands as `generic`,
  and adds coverage for: custom op returning a non-row object not being rejected;
  `sessions-get` pre-handler returning "session not found" for foreign and
  non-existent UUIDs (no existence oracle) and allowing the caller's own session;
  generic list/get failing closed when a resource declares no `scopeField`.
  Full suite: 323 passing.

- Remove FORK.md from the PR diff — it's the fork's personal README, carried in
  because the branch was cut from the fork's `main` rather than upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* docs: update CONTRIBUTING.md repo references after nanocoai migration

* fix(ci): update bump-version repo guard

* fix(ci): update update-tokens repo guard

* chore: rename remaining qwibitai/nanoclaw references to nanocoai/nanoclaw

Sweep of outbound strings, doc URLs, comments, and clone instructions
that were missed in the original org rename. One both-match case in
setup/lib/channels-remote.sh (URL detection) accepts either name so
existing forks with a `qwibitai` remote continue to resolve cleanly;
everywhere else is a straight rename.

Historical mentions left intact:
- CHANGELOG.md (v2.0.0 entry, frozen history)
- .claude/skills/add-gmail-tool/SKILL.md (pre-v2 qwibitai skill — historical)
- repo-tokens/badge.svg (auto-regenerated by update-tokens.yml)

* chore: bump version to 2.0.55

* docs: update token count to 175k tokens · 87% of context window

* fix(container): gracefully handle missing on_wake column in pre-migration session DBs

The container opens inbound.db read-only, so it can't ALTER TABLE.
If the host hasn't run migrateMessagesInTable yet (e.g., container
rebuilt before host restart), the on_wake column won't exist and
the query crashes, causing a restart loop.

Detect the column via PRAGMA table_info and conditionally include
the filter clause.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.56

* revert: remove compaction destination reminder (PR #2327)

The compacted event handler injected a system-tagged reminder into the
live query after SDK auto-compaction, which caused the agent to send
an unintended message. Reverts the four changes from #2327:

- Remove `compacted` variant from ProviderEvent union
- Restore `result` yield for compact_boundary in ClaudeProvider
- Remove compacted event handler and getAllDestinations import in poll-loop
- Remove compaction integration tests and CompactingProvider helper

Closes #2325 differently — the reminder approach is not viable.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(compact): place destination reminder at end of compaction summary

Tell the compactor to include the <message to="name"> wrapping reminder
verbatim at the END of the summary so it's the last thing the agent sees
after compaction. Previously the instruction just asked to "preserve"
routing info, which the compactor could place anywhere or summarize away.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.57

* docs: update token count to 173k tokens · 87% of context window

* fix(poll-loop): nudge agent when output lacks message wrapping

When the agent outputs bare text without <message to="..."> blocks,
nothing gets delivered — silent failure. Now the poll-loop pushes a
one-shot correction back into the active query telling the agent to
re-send with proper wrapping. Capped at once per user turn to avoid
loops; resets when a new follow-up message arrives.

Also updates destination instructions to require explicit <internal>
wrapping for scratchpad instead of treating bare text as implicit
scratchpad.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.58

* fix(permissions): skip channel-type prefix for userIds that already contain a colon

Platforms like Teams send userIds in "29:xxx" format which already
include a colon. Blindly prefixing with channelType produced double-
namespaced ids (e.g. "teams:29:xxx") that never matched the users
table, causing all approval clicks to be rejected. Mirror the
resolveOrCreateUser logic: only prefix when the raw id has no colon.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: bump version to 2.0.59

* docs: update token count to 174k tokens · 87% of context window

* fix(setup): pin OneCLI gateway version to 1.23.0

The upstream install script supports ONECLI_VERSION; use it to avoid
pulling an untested gateway release during setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(core-instructions): require message wrapping for single-destination agents

The parenthetical "(single-destination: just write)" was stale after
9db39b2 removed the bare-text routing fallback. Agents following this
hint had their responses silently dropped to scratchpad.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: bump version to 2.0.60

* setup: add files:read and files:write to Slack scope checklist

Without files:read, @chat-adapter/slack cannot download attachments —
Slack returns an HTML login page in place of file bytes and the adapter
throws a NetworkError. Bundles files:write for symmetric outbound
(files.uploadV2).

Closes #2457

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(welcome): stop emitting the greeting twice

The welcome skill told the agent to send the greeting via `send_message`,
but the destinations system prompt also requires the final response to
be wrapped in `<message to="…">` blocks (since 1d4d920). The agent
followed both, sending the greeting once via the MCP tool and once via
the wrapped final output.

- welcome/SKILL.md: drop the mechanism — "send a short, warm greeting"
  lets the system prompt steer how it's delivered.
- destinations.ts: reframe `<message>` blocks and `send_message` as the
  same delivery surface, with the explicit note that each call/block
  lands as its own message — so they compose into a sequence rather than
  reading as additive duplicates of the same content.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore: bump version to 2.0.61

* fix(destinations): remove misleading scratchpad clause from internal-tag description

Follow-up to #2467. The trailing "anything outside these tags is also
treated as scratchpad" clause contradicted the rest of the system prompt,
which requires bare text to be wrapped in `<message>` blocks. Removing it
keeps the description focused on what `<internal>` actually does.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* chore: bump version to 2.0.62

* docs(skill): align add-gmail-tool/add-gcal-tool with v2 architecture

Two pieces of post-v1 drift in the gmail/gcal skills made the instructions
either dead-code edits or silently broken installs:

1. The TOOL_ALLOWLIST edit step is redundant. claude.ts derives
   mcp__<name>__* allow-patterns dynamically from each group's
   mcpServers map (claude.ts:294-297), so registering the MCP server in
   Phase 3 already authorizes the tools. Removed the edit step, its
   pre-check, its troubleshooting attribution, and its uninstall mirror;
   replaced with an explanatory note pointing at the dynamic derivation.

2. The "edit groups/<folder>/container.json" step doesn't stick.
   materializeContainerJson rewrites that file from the central DB on
   every spawn (post-migration 014-container-configs), so hand edits are
   silently overwritten on next restart. Rewrote Phase 3 to use
   `ncl groups config add-mcp-server` (which persists to DB) for the
   MCP-server entry, and a sqlite3 json_insert workaround for the mount
   entry — with a note to switch to `ncl groups config add-mount` once
   #2395 lands. Removal step rewritten the same way using
   `remove-mcp-server` and a sqlite3 json_group_array filter.

Fixes #2488

* docs(skill): fix sqlite3/json invocations in gmail/gcal mount steps

Three issues with the DB-edit steps that ship in #2489:

- `'$[#]'` was double-quoted in the surrounding bash string, so bash
  arith-expanded `$#` (positional-arg count, 0 in interactive shell)
  before sqlite ever saw it — silently overwrote index 0 instead of
  appending. Now escaped as `'\$[#]'`.

- `sqlite3` CLI replaced with `pnpm exec tsx scripts/q.ts` — clean
  installs have no sqlite3 binary; setup/verify.ts:5 codifies that
  NanoClaw avoids depending on it.

- `strftime('%s','now')` replaced with `datetime('now')` — the column
  stores ISO strings everywhere else; mixing epoch ints made any
  consumer doing `datetime(updated_at)` parse those rows as 1970.

Also: reworded the "approval-gated" wording to distinguish container
vs host-operator-shell invocation, and added the "Why this can't be
container.json" note to add-gcal-tool (gmail had it, gcal didn't).

* fix(cli,skills): use per-install slug for service names

The `ncl` transport-error message and ~20 skill docs hardcoded v1's
`com.nanoclaw` / `nanoclaw` for launchd labels and systemd units. Under
v2 the names are slug-suffixed per checkout (`com.nanoclaw.<slug>`,
`nanoclaw-<slug>.service`), so those commands no longer match a real
service on the host.

- `src/cli/client.ts` — extract `formatTransportError` into
  `src/cli/transport-errors.ts` so it can read `install-slug` and call
  `getLaunchdLabel()` / `getSystemdUnit()`.
- `src/cli/transport-errors.test.ts` — regression test for #2484: the
  error string must not contain the bare v1 names.
- `.claude/skills/**/*.md` — replace hardcoded restart snippets with
  the canonical `source setup/lib/install-slug.sh` + `$(systemd_unit)` /
  `$(launchd_label)` pattern (or the inline subshell form where the
  snippet is a one-liner).

Closes #2484
Closes #2485

* docs(skills): tighten install-slug usage per #2493 review

- swap remaining inline subshells from `; helper` to `&& helper` so source
  failures surface as the source error instead of a downstream 'command not
  found' on the helper call
- fix two service-status checks that still grepped for the bare v1 name
  (init-first-agent, add-emacs) — same canonical inline form as the rest of
  the sweep, scoped to the per-install slug
- collapse add-parallel's verify block to the inline form so it stops
  shadowing the canonical pattern
- note 'run from your NanoClaw project root' beside every restart snippet
  that sources `setup/lib/install-slug.sh` (inline as a bash comment on
  the source line, plus parenthetical lead-ins where the snippet is
  prose-form) so the relative-path dependency is loud at the spot it
  matters

* docs(skills): consolidate project-root reminder into prose lead-ins

Replace inline `# run from your NanoClaw project root` annotations on
`source setup/lib/install-slug.sh` lines with one standalone prose
lead-in per code block. Also drop parenthetical "(run from the project
root...)" mentions where the same convention is already obvious.

* docs(skills): standardize project-root lead-in to its own line

Split the embedded forms ("... — run from your NanoClaw project root:")
into a separate `Run from your NanoClaw project root:` line directly
above the code block, so the lead-in pattern is uniform across all
restart blocks.

* docs(skills): apply v1-name fix to gmail/gcal tools

The gmail/gcal Phase 4 restart blocks and uninstall one-liners
still hardcoded `com.nanoclaw` / `restart nanoclaw`, so on a v2
install they would fail with "no such service" or kick the
wrong unit.

Phase 4 restart now uses the canonical
`source setup/lib/install-slug.sh` + `$(launchd_label)` /
`$(systemd_unit)` pattern with the standalone `Run from your
NanoClaw project root:` lead-in. Uninstall one-liners switch
to the inline-subshell form
`"$(. setup/lib/install-slug.sh && systemd_unit)"`.

(Folds in #2489's v2-alignment changes to the same two files;
the deferral noted in the original PR body is no longer needed
now that #2489 has merged.)

* chore: bump version to 2.0.63

* docs: add v2.0.63 CHANGELOG entry and RELEASING.md

CHANGELOG.md gets a rollup entry covering v2.0.55..v2.0.63 in the
project voice (bold lead-ins, [BREAKING] prefix with inline workaround,
doc links to setup/lib/install-slug.sh, no PR numbers).

RELEASING.md is new and documents the per-bump release policy starting
with v2.0.63: tag every package.json bump, mirror the CHANGELOG entry
into the GitHub Release body, append Contributors and (when relevant)
New Contributors sections, and use rollup framing when multiple bumps
collapsed into one release.

* docs(releasing): soften per-bump policy and document release channels

Two revisions in RELEASING.md based on review feedback:

1. Soften the "release per bump" claim. The policy is aspirational and
   release publication is manual, so the opening now states the goal
   ("publish a GitHub Release for every package.json version bump that
   lands on main") and acknowledges that there can be lag between a bump
   merging and the release being cut. Intent: timeliness, not strict 1:1.

2. Add a "Channels and stability" section that explicitly states NanoClaw
   ships a single channel today, distinguishes latest/stable/pinned for
   consumers, and reserves space for a future pre-release channel without
   inventing structure that does not yet exist. Folds the previous Pinning
   section into the new structure as the Pinned bullet.

* docs(changelog): drop stale docs.nanoclaw.dev link from header

The "For detailed release notes, see the full changelog on the
documentation site" line pointed at a docs portal that does not exist.
CHANGELOG.md is the canonical record, so the header now says only what
is true: all notable changes are documented in this file.

* docs(changelog): align v2.0.63 rollup line with RELEASING.md voice

RELEASING.md frames the per-bump release policy as a goal that is cut
manually, not as automation. The v2.0.63 CHANGELOG rollup line still
asserted the stronger claim ("NanoClaw publishes a GitHub Release on
every package.json version bump"), which contradicts the policy doc.
Soften to match RELEASING.md so the two land consistently on main.

* fix(cli): hydrate receiver inbound.db on approval-path destinations add/remove

The `destinations add` and `destinations remove` custom ops in the admin
CLI INSERT/DELETE rows in the central `agent_destinations` table, but
did not project the change into running sessions' `inbound.db`. The
agent-runner container reads its destination map from the per-session
projection, so until the next container spawn (`container-runner.ts`
hydrates on every wake), the running agent saw a stale map — explaining
the "dropped: unknown destination" symptom after a fresh `ncl
destinations add` even though the central row was clearly committed.

Same handler runs for both the direct-host path and the approval-execution
path because the `cli_command` approval handler in `dispatch.ts` re-enters
`dispatch()` as `caller: 'host'`, so the fix at the handler level covers
both surfaces.

Helper iterates over `getSessionsByAgentGroup(agentGroupId)` (every
active session for the affected agent), guarded by `hasTable('agent_destinations')`
and a lazy dynamic import of `writeDestinations` to keep the agent-to-agent
module optional. Per-session try/catch keeps one bad session from killing
the whole projection; failures are logged at WARN with session id + error.

Regression test invokes the dispatcher with `caller: 'host'` (the same
re-entry the approval handler uses after admin approves), with two active
sessions on the source agent group, and asserts the `destinations` row
lands in every session's inbound.db after `add` and is cleared after `remove`.

Fixes #2465

* chore: bump version to 2.0.64

* docs(changelog): add v2.0.64 entry

Documents the fix from #2510 (closes #2465) in user-facing prose
following the RELEASING.md style guide. Single-bullet release —
no rollup opener since this is a clean one-bump cycle.

* chore: drop abandoned group folders (global, main)

groups/global/ and groups/main/ are leftover state from early v1-era
experimentation with multi-channel agents (WhatsApp registered_groups.json,
Telegram group bot in addition to DM). Setup retired; only dm-with-ark
(Agent Smith) and cli-with-ark (Terminal Agent) are active in v2.db.

- groups/global/CLAUDE.md: filesystem directory already gone; only the
  tracked file remained, matching chore/pre-flash-cleanup's intent.
- groups/main/CLAUDE.md: v1-era prompt referencing /workspace/project/store/
  messages.db + registered_groups.json. No v2 DB record. Filesystem
  contents .gitignored already; only the committed CLAUDE.md was tracked.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(approvals): tighten approval-id auth + add clicker authorization

Two security improvements with tests:

1. shortApprovalId now uses crypto.randomBytes(16).toString('base64url')
   (128 bits) instead of Math.random().toString(36).slice(2,10)
   (~41 bits, brute-forceable). The id IS the secret — anyone who can
   guess a pending id can approve a credentialed action. New version
   stays inside Telegram's 64-byte callback_data budget. Exported now
   so tests can probe entropy + character set.

2. handleApprovalsResponse re-verifies the clicker is in the eligible-
   approvers list for the approval's agent group, via a new
   isAuthorizedClicker check that compares pickApprover() against the
   namespaced clickerId (${channelType}:${userId}). Without this,
   forged clicks — including userId-spoofed ones — would dispatch the
   handler. Mirrors handleSenderApprovalResponse in permissions/.

Adds tests:
- onecli-approvals.test.ts (41) — id format + entropy
- response-handler.test.ts (193) — auth-gate behavior end-to-end
- webhook-server.test.ts (23) — resolveListenConfig defaults
  loopback so the webhook port isn't LAN-exposed

Note: main has not touched onecli-approvals.ts or response-handler.ts
since the WIP base (a4346f5), so no upstream merge concerns.
Worth opening a PR upstream after the SSD flash — these are genuine
security fixes.

* feat(setup): chmod-600 env-file helpers + ignore .claude/settings.local.json

Adds setup/env-utils.ts with writeSecretEnvFile/copySecretEnvFile
helpers that explicitly chmod 0o600 after the write, fixing two
issues:

  1. fs.writeFileSync({ mode: 0o600 }) only honors the mode when
     the file is *created* — existing files keep their previous
     perms (typically 0644 from default umask). The helpers run an
     explicit chmodSync to fix legacy 0644 files in place.
  2. Channel-install flows that rewrite .env or data/env/env were
     drifting back to world-readable on each rewrite.

Migrates two callsites to the new helpers:
- setup/timezone.ts: TZ writes in .env
- setup/set-env.ts: KEY=VALUE writes in .env + data/env/env mirror

Also adds .claude/settings.local.json to .gitignore (machine-local
Claude Code permission allowlist; was getting picked up as untracked
on every clone of this repo on this host).

Note: main has not touched timezone.ts or set-env.ts since the WIP
base, so no upstream merge concerns. The other 46 setup/ files in
the WIP diff are mostly snapshot-vs-main drift, intentionally left
out.

* feat(container-skills): add context-awareness, gemini-companion, opus-escalation

Three new container skills loaded into agent sessions at runtime:

- context-awareness — model context-window limits + when to compact
  + when to route long-context tasks to Gemini instead
- gemini-companion — Gemini as a silent backend (second opinions,
  long-context, cross-validation, parallel analysis); credentials
  via credential proxy
- opus-escalation — Claude Opus 4.7 as a backend call for hard
  problems

All three are SKILL.md-only (instruction skills, no code). They
ride alongside main's existing welcome/self-customize/agent-browser/
slack-formatting/frontend-engineer/vercel-cli set under
container/skills/.

* fix(webhook-server): default-bind to loopback instead of 0.0.0.0

Extract resolveListenConfig() so the bind address is environment-driven
and unit-testable. Default to 127.0.0.1 so the webhook port is not
exposed to the LAN; set WEBHOOK_BIND=0.0.0.0 (or a specific interface
IP) to opt back into external exposure.

The existing webhook-server.test.ts imported resolveListenConfig from
the production module, but the symbol had never been extracted — the
test was effectively dead. This commit lands the missing refactor and
extends the test with the empty-string fallback, specific-interface,
and combined-env-var cases.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(security): don't leak eligible-approver roster in click-rejection log

Panel-review finding on #1: the warn log on unauthorized clicks dumped the
full `pickApprover()` result. A persistent attacker spamming forged clicks
could scrape logs to enumerate admin/owner identities (and the
scoped-vs-global structure of role grants).

Keep approvalId + action + clickerId for triage; drop the eligible field.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(security,skill): CSPRNG for module-approval ids + drop broken token estimator

Two follow-ups to the panel review on #1.

1. src/modules/approvals/primitive.ts: bump module-initiated approval ids
   from `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` (~30
   bits of non-CSPRNG entropy) to `appr-${randomBytes(16).toString('base64url')}`
   (128 bits CSPRNG). Same shape as #2545's shortApprovalId fix for the
   OneCLI path; this is the parallel fix for the requestApproval()-initiated
   path (self-mod install_packages, add_mcp_server, future module gates).
   The new isAuthorizedClicker gate is the load-bearing defense, but make
   the id itself unguessable so a hypothetical bypass of the auth check
   doesn't fall back on a 30-bit secret.

2. container/skills/context-awareness/SKILL.md: drop the
   `ls ~/.claude/projects/*/session-*.jsonl` token-estimator block. That
   path is host-side Claude Code state; inside Smith's container, sessions
   live in /workspace/{inbound,outbound}.db SQLite files, so the script
   always printed "No session file found". Replaced with prose explaining
   the actual session model and how to estimate from in-turn activity.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

---------

Co-authored-by: glifocat <[email protected]>
Co-authored-by: gavrielc <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Co-authored-by: glifocat <[email protected]>
Co-authored-by: exe.dev user <[email protected]>
Co-authored-by: Gabi Simons <[email protected]>
Co-authored-by: Daniel M <[email protected]>
Co-authored-by: Ali Goldberg <[email protected]>
Co-authored-by: Ira Abramov <[email protected]>
Co-authored-by: johnnyfish <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: krejov100 <[email protected]>
Co-authored-by: Adam Johnson <[email protected]>
Co-authored-by: Doug Daniels <[email protected]>
Co-authored-by: MoBot <[email protected]>
Co-authored-by: Petki Tamás <[email protected]>
Co-authored-by: Yaniv Golan <[email protected]>
Co-authored-by: Dvir Arad <[email protected]>
Co-authored-by: Koshkoshinsk <[email protected]>
Co-authored-by: Gabi Simons <[email protected]>
Co-authored-by: madevizslove183 <[email protected]>
Co-authored-by: Daniel Milliner <[email protected]>
Co-authored-by: ark234 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

follows-guidelines PR was created using the current contributing template PR: Fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants