fix(security): use CSPRNG for approval card ids + clicker authorization#2545
Open
smith-vosburg wants to merge 2 commits into
Open
fix(security): use CSPRNG for approval card ids + clicker authorization#2545smith-vosburg wants to merge 2 commits into
smith-vosburg wants to merge 2 commits into
Conversation
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.
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]>
1 task
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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type of Change
Description
Two related security fixes to the approval-card flow.
1. Replace
Math.random()approval ids withcrypto.randomBytes()shortApprovalId()insrc/modules/approvals/onecli-approvals.tscurrently does:This id is the secret for click approvals. The Chat SDK's Telegram adapter uses it directly as
callback_dataactionId, andresolveOneCLIApproval()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'sMath.randomis 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-bytecallback_databudget after theoa-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.tsdid 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 viapayload.userId— would dispatch the approval handler.Fix: new
isAuthorizedClicker()runspickApprover(session.agent_group_id).includes(clickerId)(whereclickerId = ${channelType}:${userId}) before invoking the handler. Mirrors the existinghandleSenderApprovalResponsecheck insrc/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:
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 propertiessrc/modules/approvals/response-handler.test.ts(new) — auth-gate end-to-end behaviorpnpm typecheckis clean. Lint only reports pre-existingno-catch-allwarnings on the touched files (not introduced by this PR).Notes
vosburg-auto/nanoclaw@ad4d5f5. The original commit also bundled asrc/webhook-server.test.tsfor aresolveListenConfigexport that doesn't exist on this repo'smain— that test was dropped from this PR. If you want the companion change (default webhook bind to127.0.0.1instead of the current0.0.0.0LAN-exposed bind insrc/webhook-server.ts:121), happy to open a separate PR.🤖 Generated with Claude Code