Compare commits

...

7 Commits

Author SHA1 Message Date
gavrielc cafba7fb18 Merge branch 'main' into cleanup/command-gate-start-and-failopen 2026-07-04 16:31:05 +03:00
github-actions[bot] 10d400ec64 chore: bump version to 2.1.28 2026-07-04 13:29:49 +00:00
gavrielc 1a9643cfa3 Merge pull request #2929 from nanocoai/feat/onecli-approval-card-summary
feat(approvals): render OneCLI approval requests from the gateway's structured summary
2026-07-04 16:29:38 +03:00
gavrielc 2ba2555032 Merge branch 'main' into feat/onecli-approval-card-summary 2026-07-04 16:28:01 +03:00
github-actions[bot] 687d7d13ac chore: bump version to 2.1.27 2026-07-04 13:26:56 +00:00
gavrielc fcee39ea14 command-gate: restore the /start filter and remove the fail-open admin check
Add '/start' back to the host FILTERED set (a Telegram fix from 866b791
was silently undone when host gating was added) so it is dropped instead
of reaching the agent as a normal message. Replace the inline isAdmin
query and its hasTable('user_roles') fail-open guard with a call to
hasAdminPrivilege; user_roles always exists (core migration 001-initial),
so the guard only ever masked a missing check. Add a focused command-gate
test covering both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:58 +03:00
gavrielc 3731e26769 feat(approvals): render OneCLI approval requests from the gateway's structured summary
The hosted OneCLI gateway (api.onecli.sh) sends a structured summary field
on ApprovalRequest — { action, details: [{label, value}] } — that the SDK's
TypeScript type doesn't declare yet. When present, render it as the approval
card body (*Action:* plus labeled fields, fencing multi-line values) instead
of the raw METHOD host/path + bodyPreview, so approvers see what the agent
is doing (e.g. To / Subject / Body of an email send) rather than an HTTP
trace.

Rendering is defensive: non-string values are coerced via JSON.stringify (a
render throw would turn into a deny via handleRequest's catch), each value is
capped at 900 chars, and a 2600-char running budget keeps the card under
Slack's 3000-char section block limit — overflow is reported with an
explicit "…N field(s) omitted for length" line instead of a failed
delivery. Without a summary, the old bodyPreview fallback remains, now
capped and with the method/host/path as a footnote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:04:08 +03:00
4 changed files with 126 additions and 24 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.26",
"version": "2.1.28",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+83
View File
@@ -0,0 +1,83 @@
/**
* Tests for the host-side command gate — filtered commands are dropped
* before reaching the container, and admin commands are gated against
* the user_roles table.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { gateCommand } from './command-gate.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { createUser } from './modules/permissions/db/users.js';
import { grantRole } from './modules/permissions/db/user-roles.js';
function now(): string {
return new Date().toISOString();
}
function seedAgentGroup(id: string): void {
createAgentGroup({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: now() });
}
function seedUser(id: string): void {
createUser({ id, kind: 'telegram', display_name: null, created_at: now() });
}
beforeEach(() => {
const db = initTestDb();
runMigrations(db);
seedAgentGroup('ag-1');
seedAgentGroup('ag-2');
});
afterEach(() => {
closeDb();
});
describe('filtered commands', () => {
it('drops /start before it reaches the container', () => {
expect(gateCommand('/start', 'telegram:1', 'ag-1')).toEqual({ action: 'filter' });
});
it('drops /start regardless of sender', () => {
expect(gateCommand('/start', null, 'ag-1')).toEqual({ action: 'filter' });
});
});
describe('admin gating goes through roles', () => {
it('denies an admin command from a non-admin user', () => {
expect(gateCommand('/clear', 'telegram:nobody', 'ag-1')).toEqual({ action: 'deny', command: '/clear' });
});
it('denies an admin command with no sender', () => {
expect(gateCommand('/clear', null, 'ag-1')).toEqual({ action: 'deny', command: '/clear' });
});
it('allows an admin command from an owner', () => {
seedUser('telegram:owner');
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
expect(gateCommand('/clear', 'telegram:owner', 'ag-1')).toEqual({ action: 'pass' });
});
it('allows an admin command from a scoped admin of the group', () => {
seedUser('telegram:admin');
grantRole({
user_id: 'telegram:admin',
role: 'admin',
agent_group_id: 'ag-1',
granted_by: null,
granted_at: now(),
});
expect(gateCommand('/clear', 'telegram:admin', 'ag-1')).toEqual({ action: 'pass' });
expect(gateCommand('/clear', 'telegram:admin', 'ag-2')).toEqual({ action: 'deny', command: '/clear' });
});
});
describe('normal messages pass through', () => {
it('passes a plain message', () => {
expect(gateCommand('hello there', 'telegram:1', 'ag-1')).toEqual({ action: 'pass' });
});
it('passes an unknown slash command', () => {
expect(gateCommand('/whatever', 'telegram:1', 'ag-1')).toEqual({ action: 'pass' });
});
});
+3 -14
View File
@@ -7,11 +7,11 @@
* "Permission denied" response written directly to messages_out
* - Normal messages: pass through unchanged
*/
import { getDb, hasTable } from './db/connection.js';
import { hasAdminPrivilege } from './modules/permissions/db/user-roles.js';
export type GateResult = { action: 'pass' } | { action: 'filter' } | { action: 'deny'; command: string };
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
const FILTERED_COMMANDS = new Set(['/start', '/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']);
/**
@@ -48,16 +48,5 @@ export function gateCommand(content: string, userId: string | null, agentGroupId
function isAdmin(userId: string | null, agentGroupId: string): boolean {
if (!userId) return false;
if (!hasTable(getDb(), 'user_roles')) return true; // no permissions module = allow all
const db = getDb();
const row = db
.prepare(
`SELECT 1 FROM user_roles
WHERE user_id = ?
AND (role = 'owner' OR role = 'admin')
AND (agent_group_id IS NULL OR agent_group_id = ?)
LIMIT 1`,
)
.get(userId, agentGroupId);
return row != null;
return hasAdminPrivilege(userId, agentGroupId);
}
+39 -9
View File
@@ -254,16 +254,46 @@ async function sweepStaleApprovals(): Promise<void> {
}
}
/** The hosted gateway's structured request summary — not yet in the SDK's
* ApprovalRequest type (observed on api.onecli.sh, 2026-07): the action being
* performed plus labeled fields (To / Subject / Body for email sends). */
interface ApprovalSummary {
action?: string;
details?: { label: string; value: string }[];
}
const SUMMARY_VALUE_EXCERPT_CHARS = 900;
function buildQuestion(request: ApprovalRequest, agentName: string): string {
const lines = [
'Credential access request',
`Agent: ${agentName}`,
'```',
`${request.method} ${request.host}${request.path}`,
'```',
];
if (request.bodyPreview) {
lines.push('Body:', '```', request.bodyPreview, '```');
const lines = [`*Agent:* ${agentName}`];
const summary = (request as ApprovalRequest & { summary?: ApprovalSummary }).summary;
if (summary?.details?.length) {
if (summary.action) lines.push(`*Action:* ${summary.action}`);
// A render bug here must never decide the request: handleRequest's catch
// returns 'deny', so stay defensive — coerce non-string values instead of
// assuming the gateway's shape, and keep the card under Slack's 3000-char
// section limit or delivery itself fails.
let budget = 2600;
for (const { label, value } of summary.details) {
const raw = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value));
const cap = Math.min(SUMMARY_VALUE_EXCERPT_CHARS, Math.max(0, budget));
if (cap === 0) {
lines.push(`_…${summary.details.length} field(s) omitted for length — see the audit payload._`);
break;
}
const v = raw.length > cap ? `${raw.slice(0, cap)}` : raw;
budget -= v.length + String(label).length + 8;
// Multi-line values (message bodies) read better fenced; short labeled
// fields (To, Subject) inline.
if (v.includes('\n')) lines.push(`*${label}:*`, '```', v, '```');
else lines.push(`*${label}:* ${v}`);
}
} else if (request.bodyPreview) {
lines.push('```', request.bodyPreview.slice(0, SUMMARY_VALUE_EXCERPT_CHARS * 2), '```');
lines.push(`_${request.method} ${request.host}${request.path}_`);
} else {
lines.push(`_${request.method} ${request.host}${request.path}_`);
}
return lines.join('\n');
}