Compare commits

..

2 Commits

Author SHA1 Message Date
Koshkoshinsk ef5d09680a fix: render WhatsApp warning title cleanly 2026-07-12 13:22:28 +03:00
Koshkoshinsk 33b33b84b9 fix: warn before shared WhatsApp setup 2026-07-12 13:05:36 +03:00
11 changed files with 148 additions and 387 deletions
+31 -5
View File
@@ -7,6 +7,34 @@ description: Add WhatsApp channel via native Baileys adapter. Direct connection
Adds WhatsApp support via the native Baileys adapter (no Chat SDK bridge).
## Number safety check (required)
Complete this check before running any install or authentication command. If the user already said they want to use their **shared**, **personal**, **main**, **existing**, or **everyday** WhatsApp number, treat it as a shared number and show the warning immediately. Do not ask the number-type question again.
Otherwise, use `AskUserQuestion`:
**Which WhatsApp number will NanoClaw use?**
- **Dedicated number (Recommended)** — a separate number used only for NanoClaw
- **Shared / personal number** — the user's existing everyday WhatsApp number
Remember the answer as `NUMBER_MODE` for the rest of this workflow.
If `NUMBER_MODE=shared`, show this warning exactly:
> ⚠️ **Risk to your WhatsApp account**
>
> Connecting your shared or personal number could cause WhatsApp to temporarily suspend or permanently ban that number. You could lose access to the WhatsApp account, chats, and groups you rely on.
>
> We strongly recommend using a separate, dedicated number for NanoClaw.
Then use `AskUserQuestion`:
- **Go back and use a dedicated number (Recommended)**
- **I understand the risk — continue with my shared number**
Do not continue with installation or authentication unless the user explicitly selects the second option. If they choose a dedicated number, set `NUMBER_MODE=dedicated` and continue without showing the warning again.
## Install
NanoClaw doesn't ship channels in trunk. This skill copies the native WhatsApp (Baileys) adapter and its `whatsapp-auth` setup step in from the `channels` branch. No Chat SDK bridge.
@@ -92,7 +120,7 @@ WhatsApp uses linked-device authentication — no API key, just a one-time pairi
### Check current state
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number".
Check if WhatsApp is already authenticated. The number safety check above is still required even when credentials already exist. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number" after completing the safety check.
```bash
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
@@ -209,9 +237,7 @@ The adapter behaves fundamentally differently depending on whether the linked nu
- **Shared/personal number** (`ASSISTANT_HAS_OWN_NUMBER` unset or not `true`) — DMs to this number and group @-tags of it address the *human*, not the bot. The adapter never emits a mention signal (`mentions: 'never'` in its declared channel defaults), so: no stranger DM ever auto-creates a messaging group or raises an admin approval card; group wirings default to a name pattern (`\b<AgentName>\b`) instead of platform mentions; auto-created chats default to `unknown_sender_policy: 'strict'`; outbound messages are prefixed with the assistant's name.
- **Dedicated number** (`ASSISTANT_HAS_OWN_NUMBER=true`) — everything sent to the number is for the bot. DMs and group mentions carry a real mention signal (`mentions: 'platform'`), unknown senders escalate via `request_approval` approval cards, and card-approved groups wire with `engage_mode: 'mention'`. No name prefix on outbound.
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
- **Dedicated number** — a separate phone/SIM for the assistant
Use the `NUMBER_MODE` selected in the required safety check. If information discovered later contradicts that selection, ask again before changing modes; switching to shared requires the same warning and explicit acknowledgement.
Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs):
@@ -224,7 +250,7 @@ ASSISTANT_HAS_OWN_NUMBER=false
### Update path: existing install, flag unset
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Ask the operator which mode applies and write it explicitly.
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Use the mode established by the required safety check and write it explicitly.
Suggest a default by comparing the authed number against the wired DM chat:
-1
View File
@@ -4,7 +4,6 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- **Claude tool allowlist reconciled with the pinned CLI, plus a tool-surface drift guard.** `TOOL_ALLOWLIST` in the agent runner named five tools that don't exist on claude-code 2.1.197 (`Task` — renamed `Agent` upstream — plus `TodoWrite`, `TeamCreate`, `TeamDelete`, `ToolSearch`); the phantom entries are removed and the comment corrected: `allowedTools` is a permission auto-approve list, **not an availability filter** — paired wire captures show no allowlist effect on the offered tool surface, and `bypassPermissions` moots its permission role. (The surface itself shows run-to-run variance in conditional tools on this CLI pin, so neither `allowedTools` nor `disallowedTools` alone should be relied on to shape what the model sees — the runner's PreToolUse hook is the deterministic block.) No wire-visible behavior change. A new wire-captured fixture (`container/agent-runner/src/providers/sdk-tools-baseline.json`) and `claude.tools.test.ts` now fail on any future claude-code CLI **or SDK** pin bump until the fixture is regenerated with `dump-sdk-tools.ts` and the lists re-verified.
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
+1 -1
View File
@@ -5,7 +5,7 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.197",
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
"@anthropic-ai/sdk": "^0.108.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
+1 -1
View File
@@ -9,7 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.197",
"@anthropic-ai/claude-agent-sdk": "^0.3.197",
"@anthropic-ai/sdk": "^0.108.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cron-parser": "^5.0.0",
@@ -1,80 +0,0 @@
/**
* Drift guard for the harness tool surface. sdk-tools-baseline.json is a wire
* capture of every tool the pinned CLI can offer under our configuration
* (regenerate with dump-sdk-tools.ts — instructions in its header). These
* tests catch upstream renames/removals when the claude-code pin moves:
* bumping container/cli-tools.json fails the version assertion until the
* fixture is regenerated and the lists below are re-verified.
*/
import fs from 'fs';
import { describe, expect, it } from 'bun:test';
import cliTools from '../../../cli-tools.json';
import { SDK_DISALLOWED_TOOLS, TOOL_ALLOWLIST } from './claude.js';
import baseline from './sdk-tools-baseline.json';
/**
* Disallow entries that do NOT exist on the pinned CLI in headless SDK mode
* (wire-verified: never offered, in both string and streaming input modes).
* Kept in SDK_DISALLOWED_TOOLS as drift insurance — if an upstream version
* starts offering one, the fixture regeneration surfaces it here and the
* entry moves out of this set.
*/
const KNOWN_ABSENT_DISALLOWED = ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'];
const installedSdkVersion = (
JSON.parse(
fs.readFileSync(new URL('../../node_modules/@anthropic-ai/claude-agent-sdk/package.json', import.meta.url), 'utf8'),
) as { version: string }
).version;
const baselineTools = new Set<string>(baseline.tools);
describe('sdk tool-surface drift guard', () => {
it('fixture matches the pinned claude-code CLI version', () => {
const pin = cliTools.find((t) => t.name === '@anthropic-ai/claude-code')?.version;
expect(baseline.cliVersion).toBe(pin);
});
it('fixture matches the installed Agent SDK version', () => {
// The SDK is a caret-free pinned dep; a bump must be captured deliberately.
expect(baseline.sdkVersion).toBe(installedSdkVersion);
});
it('allowedTools has no surface effect: the production allowlist and the bare surface match', () => {
// Paired same-run captures — passing TOOL_ALLOWLIST has never been
// observed to filter or promote a tool under bypassPermissions. If a
// future CLI makes allowedTools an availability filter, these diverge and
// this fails. (The surface has independent run-to-run variance in
// conditional tools — see the dump-sdk-tools.ts header — which pairing
// within one regen run controls for.)
expect([...baseline.tools].sort()).toEqual([...baseline.toolsBare].sort());
});
it('every allowlist entry names a real tool on this surface', () => {
for (const name of TOOL_ALLOWLIST) {
expect(baselineTools.has(name), `allowlist entry '${name}' not in captured surface`).toBe(true);
}
});
it('every disallow entry is either a real tool or documented drift insurance', () => {
for (const name of SDK_DISALLOWED_TOOLS) {
const real = baselineTools.has(name);
const insurance = KNOWN_ABSENT_DISALLOWED.includes(name);
expect(
real || insurance,
`disallow entry '${name}' is neither on the surface nor in KNOWN_ABSENT_DISALLOWED`,
).toBe(true);
}
});
it('drift-insurance entries are still absent from the surface', () => {
for (const name of KNOWN_ABSENT_DISALLOWED) {
expect(
baselineTools.has(name),
`'${name}' now exists on the surface — move it out of KNOWN_ABSENT_DISALLOWED and re-verify its disposition`,
).toBe(false);
}
});
});
+19 -22
View File
@@ -24,7 +24,7 @@ function log(msg: string): void {
// the question and blocks on the real reply.
// - EnterPlanMode / ExitPlanMode / EnterWorktree / ExitWorktree: Claude
// Code UI affordances; in a headless container they'd appear stuck.
export const SDK_DISALLOWED_TOOLS = [
const SDK_DISALLOWED_TOOLS = [
'CronCreate',
'CronDelete',
'CronList',
@@ -36,33 +36,30 @@ export const SDK_DISALLOWED_TOOLS = [
'ExitWorktree',
];
// Pre-approved tool set for NanoClaw agent containers. `allowedTools` is a
// permission auto-approve list, NOT an availability filter: paired same-run
// wire captures show no allowlist effect on the offered surface (the fixture's
// `tools` and `toolsBare` are equal; claude.tools.test.ts asserts it), and its
// permission role is moot under this runner's `bypassPermissions`. The surface
// itself has run-to-run variance in conditional tools (see dump-sdk-tools.ts
// header), so never rely on this list — or on `disallowedTools` alone — to
// shape what the model sees. Kept as the accurate pre-approval set for a
// hypothetical non-bypass mode, and because the per-server `mcpAllowPattern`
// entries derived at the call site are retained (MCP invocation-gating under
// non-bypass modes is unverified). Exported for the fixture regenerator and
// tests.
export const TOOL_ALLOWLIST = [
'Agent',
// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived
// at the call site from the registered `mcpServers` map so that any server
// added via `add_mcp_server` (or wired in container.json directly) is
// reachable to the agent — without this, the SDK's allowedTools filter
// silently drops every MCP namespace not listed here.
const TOOL_ALLOWLIST = [
'Bash',
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'NotebookEdit',
'Read',
'SendMessage',
'Skill',
'WebSearch',
'WebFetch',
'Task',
'TaskOutput',
'TaskStop',
'WebFetch',
'WebSearch',
'Write',
'TeamCreate',
'TeamDelete',
'SendMessage',
'TodoWrite',
'ToolSearch',
'Skill',
'NotebookEdit',
];
// MCP server names are sanitized by the SDK when forming tool prefixes:
@@ -1,163 +0,0 @@
/**
* Regenerates sdk-tools-baseline.json — the bare SDK tool-surface fixture
* asserted by claude.tools.test.ts.
*
* Must run INSIDE the agent container image (the pinned CLI binary only
* exists there). From the repo root:
*
* docker run --rm --network none \
* -v "$PWD/container/agent-runner/src":/app/src:ro \
* --entrypoint bun <nanoclaw-agent image> /app/src/providers/dump-sdk-tools.ts \
* > container/agent-runner/src/providers/sdk-tools-baseline.json
*
* Three captures:
* - `tools` : with the production TOOL_ALLOWLIST.
* - `toolsBare` : with no allowedTools.
* - `toolsDisallowProbe` : with disallowedTools=[DISALLOW_PROBE] only.
* The drift test asserts tools === toolsBare: no allowlist effect on the
* offered surface has ever been observed in a paired same-run capture, so the
* list is permission-layer only (moot under bypassPermissions) — if a CLI/SDK
* bump makes the allowlist shape the surface, that assertion fails and forces
* a re-read of its semantics.
*
* MEASURED NONDETERMINISM on the pinned CLI (2.1.197): across separate query
* invocations with byte-identical options, (a) conditional tools — Glob and
* Grep — flicker in and out of the surface, and (b) `disallowedTools`
* sometimes strips flag-gated tools (Workflow, DesignSync, EnterWorktree) and
* sometimes ignores them entirely; each single query is internally coherent.
* Consequences: schema-stripping via disallowedTools is BEST-EFFORT — the
* deterministic enforcement is the runner's PreToolUse hook, which blocks the
* invocation regardless of whether the schema shipped. `toolsDisallowProbe`
* records which behavior the regen run happened to observe (no test asserts
* stripping — a fixed assertion on a nondeterministic mechanism would be a
* coin-flip). If a regen produces tools !== toolsBare or a surface missing
* Glob/Grep, rerun it — you sampled the variant bucket.
*
* Agent-teams is enabled via a temp settings.json (wire-verified: settings
* env strictly beats SDK options env).
*
* Zero API traffic: ANTHROPIC_BASE_URL points at an in-process stub answering
* 401; the full tools array rides on the first /v1/messages request, captured
* before the run dies on the auth error. The fixture records WIRE tool names
* (the SDK init message reports legacy aliases, e.g. `Task` for wire `Agent` —
* do not swap this to an init capture).
*/
import { execFileSync } from 'child_process';
import fs from 'fs';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { TOOL_ALLOWLIST } from './claude.js';
let requests: string[] = [];
let captured: (() => void) | null = null;
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
const url = new URL(req.url);
const body = await req.text();
if (url.pathname.includes('/messages')) {
requests.push(body);
captured?.();
}
return new Response(
JSON.stringify({ type: 'error', error: { type: 'authentication_error', message: 'fixture-capture-stub' } }),
{ status: 401, headers: { 'content-type': 'application/json' } },
);
},
});
const HOME = '/tmp/dump-sdk-tools-home';
const CWD = '/tmp/dump-sdk-tools-ws';
fs.mkdirSync(`${HOME}/.claude`, { recursive: true });
fs.mkdirSync(CWD, { recursive: true });
fs.writeFileSync(
`${HOME}/.claude/settings.json`,
JSON.stringify({ env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } }, null, 2),
);
/**
* Diagnostic probe for disallowedTools: a flag-gated tool whose stripping is
* nondeterministic on the current pin (see header). The fixture records what
* this regen run observed; future pins can be compared against it.
*/
export const DISALLOW_PROBE = 'Workflow';
/** Run one capture and return the sorted wire tool names. */
async function capture(opts?: { allowedTools?: string[]; disallowedTools?: string[] }): Promise<string[]> {
requests = [];
const firstRequest = new Promise<void>((resolve) => {
captured = resolve;
});
const q = query({
prompt: 'fixture capture: reply with one word',
options: {
cwd: CWD,
pathToClaudeCodeExecutable: '/pnpm/claude',
systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const },
env: {
...process.env,
HOME,
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.port}`,
ANTHROPIC_API_KEY: 'fixture-dummy-key',
ANTHROPIC_AUTH_TOKEN: undefined,
},
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['user'],
...(opts?.allowedTools ? { allowedTools: opts.allowedTools } : {}),
...(opts?.disallowedTools ? { disallowedTools: opts.disallowedTools } : {}),
},
});
void (async () => {
try {
for await (const _m of q) {
/* drain until the auth error kills the run */
}
} catch {
/* expected: 401 from the stub */
}
})();
await Promise.race([firstRequest, Bun.sleep(75_000)]);
await Bun.sleep(1_500); // let retries land so we can pick the largest body
if (requests.length === 0) {
console.error('[dump-sdk-tools] no /v1/messages request captured');
process.exit(1);
}
const biggest = requests.reduce((a, b) => (b.length > a.length ? b : a));
const parsed = JSON.parse(biggest) as { tools?: Array<{ name: string }> };
return [...new Set((parsed.tools ?? []).map((t) => t.name))].sort();
}
const tools = await capture({ allowedTools: TOOL_ALLOWLIST });
const toolsBare = await capture();
const toolsDisallowProbe = await capture({ disallowedTools: [DISALLOW_PROBE] });
const cliVersionRaw = execFileSync('/pnpm/claude', ['--version'], { encoding: 'utf8' }).trim();
const cliVersion = cliVersionRaw.split(/\s+/)[0];
const sdkVersion = (
JSON.parse(fs.readFileSync('/app/node_modules/@anthropic-ai/claude-agent-sdk/package.json', 'utf8')) as {
version: string;
}
).version;
console.log(
JSON.stringify(
{
cliVersion,
sdkVersion,
capturedAt: new Date().toISOString(),
capture:
'wire names; tools=production allowlist surface, toolsBare=no allowedTools, toolsDisallowProbe=disallowedTools:[probe] only; teams on',
disallowProbe: DISALLOW_PROBE,
tools,
toolsBare,
toolsDisallowProbe,
},
null,
2,
),
);
process.exit(0);
@@ -1,94 +0,0 @@
{
"cliVersion": "2.1.197",
"sdkVersion": "0.3.197",
"capturedAt": "2026-07-12T08:30:04.954Z",
"capture": "wire names; tools=production allowlist surface, toolsBare=no allowedTools, toolsDisallowProbe=disallowedTools:[probe] only; teams on",
"disallowProbe": "Workflow",
"tools": [
"Agent",
"Bash",
"CronCreate",
"CronDelete",
"CronList",
"DesignSync",
"Edit",
"EnterWorktree",
"ExitWorktree",
"Glob",
"Grep",
"NotebookEdit",
"Read",
"ReportFindings",
"ScheduleWakeup",
"SendMessage",
"Skill",
"TaskCreate",
"TaskGet",
"TaskList",
"TaskOutput",
"TaskStop",
"TaskUpdate",
"WebFetch",
"WebSearch",
"Workflow",
"Write"
],
"toolsBare": [
"Agent",
"Bash",
"CronCreate",
"CronDelete",
"CronList",
"DesignSync",
"Edit",
"EnterWorktree",
"ExitWorktree",
"Glob",
"Grep",
"NotebookEdit",
"Read",
"ReportFindings",
"ScheduleWakeup",
"SendMessage",
"Skill",
"TaskCreate",
"TaskGet",
"TaskList",
"TaskOutput",
"TaskStop",
"TaskUpdate",
"WebFetch",
"WebSearch",
"Workflow",
"Write"
],
"toolsDisallowProbe": [
"Agent",
"Bash",
"CronCreate",
"CronDelete",
"CronList",
"DesignSync",
"Edit",
"EnterWorktree",
"ExitWorktree",
"Glob",
"Grep",
"NotebookEdit",
"Read",
"ReportFindings",
"ScheduleWakeup",
"SendMessage",
"Skill",
"TaskCreate",
"TaskGet",
"TaskList",
"TaskOutput",
"TaskStop",
"TaskUpdate",
"WebFetch",
"WebSearch",
"Workflow",
"Write"
]
}
+4 -7
View File
@@ -148,12 +148,9 @@ class ClaudeProvider implements AgentProvider {
systemPrompt: input.systemContext?.instructions
? { type: 'preset', preset: 'claude_code', append: input.systemContext.instructions }
: undefined,
// Permission auto-approve list (NOT an availability filter — wire
// captures show no allowlist effect on the offered tool surface, and
// bypassPermissions moots its permission role). Kept accurate for a
// hypothetical non-bypass mode; the `mcp__<server>__*` patterns are
// retained because MCP invocation-gating under non-bypass modes is
// unverified. See sdk-tools-baseline.json + claude.tools.test.ts.
// Base tools plus one `mcp__<server>__*` pattern per registered MCP
// server — without the explicit MCP patterns the SDK's allowedTools
// filter silently drops every MCP namespace.
allowedTools: [...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern)],
disallowedTools: SDK_DISALLOWED_TOOLS,
env: this.env,
@@ -198,7 +195,7 @@ SDK message (so the idle timer stays honest) and maps recognized messages to `Pr
**Claude-specific behavior inside the provider:**
- `MessageStream` for async iterable input (push-based follow-ups)
- Resume via the SDK `resume` option keyed on the stored `continuation` (the SDK session ID) — no separate resume-at cursor
- `TOOL_ALLOWLIST` (Agent, Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Skill, …) — a permission auto-approve list, not an availability filter (moot under `bypassPermissions`; wire-verified, see `claude.tools.test.ts`) — extended at the call site with a `mcp__<server>__*` pattern per registered MCP server; `SDK_DISALLOWED_TOOLS` blocks SDK builtins that collide with NanoClaw's own scheduling/interaction model (CronCreate/Delete/List, ScheduleWakeup, AskUserQuestion, Enter/ExitPlanMode, Enter/ExitWorktree)
- `TOOL_ALLOWLIST` (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Task, Skill, …) extended at the call site with a `mcp__<server>__*` pattern per registered MCP server; `SDK_DISALLOWED_TOOLS` blocks SDK builtins that collide with NanoClaw's own scheduling/interaction model (CronCreate/Delete/List, ScheduleWakeup, AskUserQuestion, Enter/ExitPlanMode, Enter/ExitWorktree)
- **PreToolUse hook** records the current tool + its declared timeout to `container_state` (so the host sweep widens its stuck tolerance while a long Bash runs) and, as defense-in-depth, blocks any `SDK_DISALLOWED_TOOLS` call that slips through. It does **not** sanitize bash env vars — there is no such hook.
- **PostToolUse / PostToolUseFailure** hooks clear the in-flight tool
- **PreCompact** hook archives the transcript to `conversations/` before compaction
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
brightSelect: vi.fn(),
note: vi.fn(),
userInput: vi.fn(),
}));
vi.mock('../lib/bright-select.js', () => ({
brightSelect: mocks.brightSelect,
}));
vi.mock('../lib/theme.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../lib/theme.js')>()),
note: mocks.note,
}));
vi.mock('../logs.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../logs.js')>()),
userInput: mocks.userInput,
}));
import { BACK_TO_CHANNEL_SELECTION } from '../lib/back-nav.js';
import { runWhatsAppChannel } from './whatsapp.js';
describe('WhatsApp shared-number risk gate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows the warning and requires explicit acknowledgement for a shared number', async () => {
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('continue').mockResolvedValueOnce('back');
const result = await runWhatsAppChannel('Daniel');
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining('temporarily suspend or permanently ban that number'),
'Risk to your WhatsApp account',
);
expect(mocks.userInput).toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', 'true');
});
it('does not show the warning for a dedicated number', async () => {
mocks.brightSelect.mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
const result = await runWhatsAppChannel('Daniel');
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
expect(mocks.note).not.toHaveBeenCalled();
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
});
it('switches to dedicated mode when the user declines the shared-number risk', async () => {
mocks.brightSelect.mockResolvedValueOnce('shared').mockResolvedValueOnce('dedicated').mockResolvedValueOnce('back');
const result = await runWhatsAppChannel('Daniel');
expect(result).toBe(BACK_TO_CHANNEL_SELECTION);
expect(mocks.note).toHaveBeenCalledOnce();
expect(mocks.userInput).not.toHaveBeenCalledWith('whatsapp_shared_risk_acknowledged', expect.anything());
});
});
+29 -13
View File
@@ -6,8 +6,8 @@
*
* 1. Ask whether the agent gets a dedicated number or shares the
* operator's personal one. Personal interception screen that spells
* out self-chat-only mode and steers toward alternatives (default is
* back to channel selection)
* out the account-ban risk and self-chat-only mode (default is switching
* to a dedicated number)
* 2. Ask how to authenticate (QR code in terminal, default, or pairing code)
* 3. If pairing-code: collect the phone number
* 4. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
@@ -68,7 +68,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
let mode: 'dedicated' | 'shared' = ownership;
if (mode === 'shared') {
const proceed = await confirmSharedNumber();
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
if (proceed === 'dedicated') mode = 'dedicated';
}
const method = await askAuthMethod();
@@ -121,7 +121,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<ChannelFl
// Chatting from the bot's own number IS the shared-number setup —
// route through the same interception screen as the up-front pick.
const proceed = await confirmSharedNumber();
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
if (proceed === 'dedicated') return BACK_TO_CHANNEL_SELECTION;
mode = 'shared';
}
}
@@ -221,13 +221,19 @@ async function askNumberOwnership(): Promise<'dedicated' | 'shared' | 'back'> {
}
/**
* Interception screen for the shared-number path: make the self-chat-only
* tradeoff explicit and steer toward alternatives before any install or
* auth happens. Default is bailing back to channel selection.
* Interception screen for the shared-number path: make the account-ban risk
* and self-chat-only tradeoff explicit before any install or auth happens.
* Default is switching to a dedicated number.
*/
async function confirmSharedNumber(): Promise<'continue' | 'back'> {
async function confirmSharedNumber(): Promise<'continue' | 'dedicated'> {
note(
[
'Connecting your shared or personal number could cause WhatsApp to',
'temporarily suspend or permanently ban that number. You could lose access',
'to the WhatsApp account, chats, and groups you rely on.',
'',
'We strongly recommend using a separate, dedicated number for NanoClaw.',
'',
'On your personal number, the agent lives only in your "You" / self-chat.',
'Messages other people send you are ignored entirely — never read, never',
'answered, never flagged for approval. Nobody else can talk to the agent.',
@@ -238,19 +244,29 @@ async function confirmSharedNumber(): Promise<'continue' | 'back'> {
`${brandBold('a dedicated WhatsApp number')} — spare SIM, eSIM, or old phone`,
`${brandBold('/add-whatsapp-cloud')} — the official Meta Business API`,
].join('\n'),
'Personal number = self-chat only',
'Risk to your WhatsApp account',
);
const choice = ensureAnswer(
await brightSelect({
message: 'How would you like to proceed?',
options: [
{ value: 'back', label: '← Pick a different channel' },
{ value: 'continue', label: 'Continue — self-chat only' },
{
value: 'dedicated',
label: 'Go back and use a dedicated number',
hint: 'recommended',
},
{
value: 'continue',
label: 'I understand the risk — continue with my shared number',
},
],
initialValue: 'back',
initialValue: 'dedicated',
}),
) as 'continue' | 'back';
) as 'continue' | 'dedicated';
setupLog.userInput('whatsapp_shared_confirm', choice);
if (choice === 'continue') {
setupLog.userInput('whatsapp_shared_risk_acknowledged', 'true');
}
return choice;
}