Compare commits

..

1 Commits

Author SHA1 Message Date
Moshe Krupper 9d08ecd57d feat: operator approval-resolution verbs for ncl
Add `approve`, `reject`, and `reject-with-reason` to the `ncl approvals`
resource (previously list/get only). They let the host CLI resolve a
pending approval — the headless equivalent of a channel button click —
routed through the SAME authorization (isAuthorizedApprovalClick) and
resolution (handleApprovalsResponse for approve/plain-reject;
finalizeReject with an inline reason for reject-with-reason) a real card
click uses. Only the ingress differs.

Host-only: an agent can never resolve an approval (self-approval of its
own held action). `--as-user <approver>` names the approver identity,
checked against the pending row exactly as a click's user id would be —
asserted, not authenticated, which is sound because the ncl socket is
owner-only and a host caller already bypasses the approval gate.

Logic lives in resolveApprovalFromCli (src/cli/resources/approvals-resolve.ts);
isAuthorizedApprovalClick is exported so both ingresses share one check.
Enables headless/ops approval and end-to-end approval-lifecycle tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:32:18 +03:00
8 changed files with 342 additions and 125 deletions
+5 -31
View File
@@ -7,34 +7,6 @@ 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.
@@ -120,7 +92,7 @@ WhatsApp uses linked-device authentication — no API key, just a one-time pairi
### Check current state
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.
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number".
```bash
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
@@ -237,7 +209,9 @@ 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.
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.
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
Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs):
@@ -250,7 +224,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. Use the mode established by the required safety check 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. Ask the operator which mode applies and write it explicitly.
Suggest a default by comparing the authed number against the wired DM chat:
+1 -1
View File
@@ -109,7 +109,7 @@ ncl help
| tasks | list, get, create, update, cancel, pause, resume, delete, run, append-log | Scheduled tasks for an agent group |
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get | Pending approval requests (read-only) |
| approvals | list, get, approve, reject, reject-with-reason | Pending approval requests; `approve`/`reject`/`reject-with-reason` resolve one from the host CLI (operator-only, `--as-user <approver>`) via the same auth + resolution as a channel button |
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
-63
View File
@@ -1,63 +0,0 @@
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());
});
});
+13 -29
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 the account-ban risk and self-chat-only mode (default is switching
* to a dedicated number)
* out self-chat-only mode and steers toward alternatives (default is
* back to channel selection)
* 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 === 'dedicated') mode = 'dedicated';
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
}
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 === 'dedicated') return BACK_TO_CHANNEL_SELECTION;
if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION;
mode = 'shared';
}
}
@@ -221,19 +221,13 @@ async function askNumberOwnership(): Promise<'dedicated' | 'shared' | 'back'> {
}
/**
* 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.
* 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.
*/
async function confirmSharedNumber(): Promise<'continue' | 'dedicated'> {
async function confirmSharedNumber(): Promise<'continue' | 'back'> {
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.',
@@ -244,29 +238,19 @@ async function confirmSharedNumber(): Promise<'continue' | 'dedicated'> {
`${brandBold('a dedicated WhatsApp number')} — spare SIM, eSIM, or old phone`,
`${brandBold('/add-whatsapp-cloud')} — the official Meta Business API`,
].join('\n'),
'Risk to your WhatsApp account',
'Personal number = self-chat only',
);
const choice = ensureAnswer(
await brightSelect({
message: 'How would you like to proceed?',
options: [
{
value: 'dedicated',
label: 'Go back and use a dedicated number',
hint: 'recommended',
},
{
value: 'continue',
label: 'I understand the risk — continue with my shared number',
},
{ value: 'back', label: '← Pick a different channel' },
{ value: 'continue', label: 'Continue — self-chat only' },
],
initialValue: 'dedicated',
initialValue: 'back',
}),
) as 'continue' | 'dedicated';
) as 'continue' | 'back';
setupLog.userInput('whatsapp_shared_confirm', choice);
if (choice === 'continue') {
setupLog.userInput('whatsapp_shared_risk_acknowledged', 'true');
}
return choice;
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Operator approval-resolution verbs (`ncl approvals approve|reject|
* reject-with-reason`). Drives `resolveApprovalFromCli` directly with a
* fabricated caller context + seeded DB state, asserting the host-only guard,
* the authorization check, and that each decision routes to the real
* resolution (handler run on approve; row consumed; reason relayed).
*/
import * as fs from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createAgentGroup } from '../../db/agent-groups.js';
import { closeDb, initTestDb, runMigrations } from '../../db/index.js';
import { createPendingApproval, createSession, getPendingApproval } from '../../db/sessions.js';
import { registerApprovalHandler } from '../../modules/approvals/primitive.js';
import { grantRole } from '../../modules/permissions/db/user-roles.js';
import { upsertUser } from '../../modules/permissions/db/users.js';
import { initSessionFolder } from '../../session-manager.js';
import type { CallerContext } from '../frame.js';
import { resolveApprovalFromCli } from './approvals-resolve.js';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-approvals-resolve' };
});
const TEST_DIR = '/tmp/nanoclaw-test-approvals-resolve';
const HOST: CallerContext = { caller: 'host' };
const AGENT: CallerContext = { caller: 'agent', sessionId: 'sess-1', agentGroupId: 'ag-1', messagingGroupId: 'mg-1' };
const now = () => new Date().toISOString();
function seedApproval(approvalId: string, action: string): void {
createPendingApproval({
approval_id: approvalId,
session_id: 'sess-1',
request_id: approvalId,
action,
payload: JSON.stringify({}),
created_at: now(),
title: 'Test approval',
options_json: JSON.stringify([]),
});
}
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
createSession({
id: 'sess-1',
agent_group_id: 'ag-1',
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: now(),
created_at: now(),
});
initSessionFolder('ag-1', 'sess-1');
upsertUser({ id: 'cli:owner', kind: 'cli', display_name: 'Owner', created_at: now() });
grantRole({ user_id: 'cli:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
upsertUser({ id: 'cli:stranger', kind: 'cli', display_name: 'Stranger', created_at: now() });
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
describe('resolveApprovalFromCli — operator approval resolution', () => {
it('rejects an agent caller (self-approval guard) and leaves the row', async () => {
seedApproval('appr-1', 'cli_command');
await expect(resolveApprovalFromCli({ id: 'appr-1', as_user: 'cli:owner' }, AGENT, 'approve')).rejects.toThrow(
/operator \(host\)/,
);
expect(getPendingApproval('appr-1')).toBeTruthy();
});
it('errors on a missing approval', async () => {
await expect(resolveApprovalFromCli({ id: 'nope', as_user: 'cli:owner' }, HOST, 'approve')).rejects.toThrow(
/no pending approval/,
);
});
it('rejects an unauthorized approver and leaves the row', async () => {
seedApproval('appr-2', 'cli_command');
await expect(resolveApprovalFromCli({ id: 'appr-2', as_user: 'cli:stranger' }, HOST, 'approve')).rejects.toThrow(
/not authorized/,
);
expect(getPendingApproval('appr-2')).toBeTruthy();
});
it('requires --id and --as-user', async () => {
await expect(resolveApprovalFromCli({ as_user: 'cli:owner' }, HOST, 'approve')).rejects.toThrow(/--id is required/);
await expect(resolveApprovalFromCli({ id: 'x' }, HOST, 'approve')).rejects.toThrow(/--as-user is required/);
});
it('approve runs the registered action handler and consumes the row', async () => {
const calls: string[] = [];
registerApprovalHandler('cli_test_approve', async () => {
calls.push('ran');
});
seedApproval('appr-3', 'cli_test_approve');
const res = await resolveApprovalFromCli({ id: 'appr-3', as_user: 'cli:owner' }, HOST, 'approve');
expect(calls).toEqual(['ran']);
expect(res).toMatchObject({ approval_id: 'appr-3', action: 'cli_test_approve', resolved: 'approve' });
expect(getPendingApproval('appr-3')).toBeUndefined();
});
it('reject consumes the row without running the action handler', async () => {
const calls: string[] = [];
registerApprovalHandler('cli_test_reject', async () => {
calls.push('ran');
});
seedApproval('appr-4', 'cli_test_reject');
const res = await resolveApprovalFromCli({ id: 'appr-4', as_user: 'cli:owner' }, HOST, 'reject');
expect(calls).toEqual([]);
expect(res.resolved).toBe('reject');
expect(getPendingApproval('appr-4')).toBeUndefined();
});
it('reject-with-reason consumes the row and carries the trimmed reason', async () => {
seedApproval('appr-5', 'cli_command');
const res = await resolveApprovalFromCli(
{ id: 'appr-5', as_user: 'cli:owner', reason: ' not this quarter ' },
HOST,
'reject-with-reason',
);
expect(res).toMatchObject({ approval_id: 'appr-5', resolved: 'reject', reason: 'not this quarter' });
expect(getPendingApproval('appr-5')).toBeUndefined();
});
it('reject-with-reason requires a reason and leaves the row on omission', async () => {
seedApproval('appr-6', 'cli_command');
await expect(
resolveApprovalFromCli({ id: 'appr-6', as_user: 'cli:owner' }, HOST, 'reject-with-reason'),
).rejects.toThrow(/--reason is required/);
expect(getPendingApproval('appr-6')).toBeTruthy();
});
it('namespaces a bare --as-user against the cli channel', async () => {
seedApproval('appr-7', 'cli_command');
// 'owner' → 'cli:owner', the seeded owner
const res = await resolveApprovalFromCli({ id: 'appr-7', as_user: 'owner' }, HOST, 'reject');
expect(res.resolved).toBe('reject');
expect(getPendingApproval('appr-7')).toBeUndefined();
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Operator-side approval resolution the host-CLI equivalent of a channel
* button click.
*
* `ncl approvals approve|reject|reject-with-reason` routes through the SAME
* authorization (`isAuthorizedApprovalClick`) and resolution the click path
* uses `handleApprovalsResponse` for approve/plain-reject, `finalizeReject`
* with an inline reason for reject-with-reason (the operator supplies the reason
* directly, so the DM prompt-and-capture flow is skipped). Only the ingress
* differs from a rendered card button.
*
* Host-only: an agent can never resolve an approval that would let it
* self-approve its own held action. `--as-user` is the approver identity the
* operator resolves as, checked against the pending row exactly as a click's
* user id would be. `--as-user` is asserted, not authenticated which is sound
* because the ncl socket is owner-only and a host caller already bypasses the
* approval gate outright.
*/
import { getPendingApproval, getSession } from '../../db/sessions.js';
import { finalizeReject } from '../../modules/approvals/finalize.js';
import { handleApprovalsResponse, isAuthorizedApprovalClick } from '../../modules/approvals/response-handler.js';
import type { ResponsePayload } from '../../response-registry.js';
import type { CallerContext } from '../frame.js';
export type ResolveDecision = 'approve' | 'reject' | 'reject-with-reason';
export interface ResolveResult {
approval_id: string;
action: string;
resolved: 'approve' | 'reject';
reason?: string;
}
/** Matches reason-capture's cap so an operator reason and a chat reason relay identically. */
const MAX_REASON_LEN = 280;
function clampReason(raw: string): string {
const trimmed = raw.trim();
return trimmed.length <= MAX_REASON_LEN ? trimmed : trimmed.slice(0, MAX_REASON_LEN - 1) + '…';
}
export async function resolveApprovalFromCli(
args: Record<string, unknown>,
ctx: CallerContext,
decision: ResolveDecision,
): Promise<ResolveResult> {
if (ctx.caller !== 'host') {
throw new Error('approvals can only be resolved by an operator (host), not an agent');
}
const id = String(args.id ?? '').trim();
const asUserRaw = String(args.as_user ?? '').trim();
if (!id) throw new Error('--id is required');
if (!asUserRaw) throw new Error('--as-user is required (the approver identity, e.g. cli:local)');
const approver = asUserRaw.includes(':') ? asUserRaw : `cli:${asUserRaw}`;
const approval = getPendingApproval(id);
if (!approval) throw new Error(`no pending approval: ${id}`);
const payload: ResponsePayload = {
questionId: id,
value: decision === 'approve' ? 'approve' : 'reject',
userId: approver,
channelType: 'cli',
platformId: '',
threadId: null,
};
if (!isAuthorizedApprovalClick(approval, payload)) {
throw new Error(
`${approver} is not authorized to resolve approval ${id} — must be its named approver, or an admin/owner of the requesting agent group`,
);
}
if (decision === 'reject-with-reason') {
const reason = clampReason(String(args.reason ?? ''));
if (!reason) throw new Error('--reason is required for reject-with-reason');
if (!approval.session_id) throw new Error(`approval ${id} has no session to notify`);
const session = getSession(approval.session_id);
if (!session) throw new Error(`approval ${id} session not found`);
await finalizeReject(approval, session, approver, reason);
return { approval_id: id, action: approval.action, resolved: 'reject', reason };
}
// approve / plain reject → the same path a card click drives (handler +
// grant-carrying replay on approve; finalizeReject on plain reject).
await handleApprovalsResponse(payload);
return { approval_id: id, action: approval.action, resolved: decision === 'approve' ? 'approve' : 'reject' };
}
+71
View File
@@ -1,4 +1,5 @@
import { registerResource } from '../crud.js';
import { resolveApprovalFromCli } from './approvals-resolve.js';
registerResource({
name: 'approval',
@@ -50,4 +51,74 @@ registerResource({
{ name: 'options_json', type: 'json', description: 'Card button options as JSON array.' },
],
operations: { list: 'open', get: 'open' },
// Operator resolution verbs — the host-CLI equivalent of a channel button
// click. Host-only (enforced in resolveApprovalFromCli); an agent can never
// resolve an approval. Same auth + resolution as a real click.
customOperations: {
approve: {
access: 'open',
description:
'Approve a pending approval and run its action (operator only). Runs the same authorization and resolution as a channel button click.',
args: [
{ name: 'id', type: 'string', description: 'Approval id (from `ncl approvals list`).', required: true },
{
name: 'as_user',
type: 'string',
description:
'Approver identity to resolve as — a namespaced user id (e.g. cli:local). Must be an authorized approver of the row.',
required: true,
},
],
examples: ['ncl approvals approve --id appr-… --as-user cli:local'],
handler: (args, ctx) => resolveApprovalFromCli(args, ctx, 'approve'),
formatHuman: (data) => {
const r = data as { action: string; approval_id: string };
return `Approved ${r.action} (${r.approval_id}) and ran its action.`;
},
},
reject: {
access: 'open',
description: 'Reject a pending approval (operator only).',
args: [
{ name: 'id', type: 'string', description: 'Approval id (from `ncl approvals list`).', required: true },
{
name: 'as_user',
type: 'string',
description: 'Approver identity to resolve as (e.g. cli:local). Must be authorized.',
required: true,
},
],
examples: ['ncl approvals reject --id appr-… --as-user cli:local'],
handler: (args, ctx) => resolveApprovalFromCli(args, ctx, 'reject'),
formatHuman: (data) => {
const r = data as { action: string; approval_id: string };
return `Rejected ${r.action} (${r.approval_id}).`;
},
},
'reject-with-reason': {
access: 'open',
description: 'Reject a pending approval and relay a one-line reason to the requesting agent (operator only).',
args: [
{ name: 'id', type: 'string', description: 'Approval id (from `ncl approvals list`).', required: true },
{
name: 'as_user',
type: 'string',
description: 'Approver identity to resolve as (e.g. cli:local). Must be authorized.',
required: true,
},
{
name: 'reason',
type: 'string',
description: 'One-line reason relayed to the agent (trimmed to 280 chars).',
required: true,
},
],
examples: ['ncl approvals reject-with-reason --id appr-… --as-user cli:local --reason "not this quarter"'],
handler: (args, ctx) => resolveApprovalFromCli(args, ctx, 'reject-with-reason'),
formatHuman: (data) => {
const r = data as { action: string; approval_id: string };
return `Rejected ${r.action} (${r.approval_id}) with reason.`;
},
},
},
});
+1 -1
View File
@@ -131,7 +131,7 @@ function namespacedUserId(payload: ResponsePayload): string | null {
return payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`;
}
function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponsePayload): boolean {
export function isAuthorizedApprovalClick(approval: PendingApproval, payload: ResponsePayload): boolean {
const userId = namespacedUserId(payload);
if (!userId) return false;