talon-agent 5.5.1 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/backend/claude-sdk/options.ts +29 -5
- package/src/bootstrap.ts +2 -0
- package/src/core/config/index.ts +16 -0
- package/src/core/mcp-hub/guest-scope.ts +142 -0
- package/src/core/mcp-hub/index.ts +21 -0
- package/src/core/mcp-hub/talon-server.ts +21 -4
- package/src/frontend/telegram/commands/memory.ts +25 -3
package/package.json
CHANGED
|
@@ -34,6 +34,16 @@ import { reportToolFingerprint } from "../runtime/cache/cache-metrics.js";
|
|
|
34
34
|
import { log, logError } from "../../util/log.js";
|
|
35
35
|
import { getConfig, getBridgePort } from "./state.js";
|
|
36
36
|
import { ALLOWED_TOOLS_CHAT, EFFORT_MAP } from "./constants.js";
|
|
37
|
+
import {
|
|
38
|
+
isGuestChat,
|
|
39
|
+
isGuestPluginAllowed,
|
|
40
|
+
} from "../../core/mcp-hub/guest-scope.js";
|
|
41
|
+
import { VALID_TOOL_FRONTENDS } from "../../core/mcp-hub/talon-server.js";
|
|
42
|
+
|
|
43
|
+
/** `telegram-tools`, `whatsapp-tools`, ... — the hub's own frontend servers. */
|
|
44
|
+
function isFrontendToolServerName(name: string): boolean {
|
|
45
|
+
return name.endsWith("-tools") && VALID_TOOL_FRONTENDS.has(name.slice(0, -6));
|
|
46
|
+
}
|
|
37
47
|
|
|
38
48
|
/**
|
|
39
49
|
* Built-in SDK tools that Talon's native tool set replaces when
|
|
@@ -383,14 +393,28 @@ export function buildSdkOptions(
|
|
|
383
393
|
const { postToolUseFailureHook, postToolBatchHook } =
|
|
384
394
|
buildTurnTerminatorHooks();
|
|
385
395
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
396
|
+
// Guest DMs (non-operator) get no SDK built-ins at all — Bash/Read/
|
|
397
|
+
// Write/Skill live outside the hub, so the hub's guest scope can't hide
|
|
398
|
+
// them — and only the plugin servers the guest scope allows.
|
|
399
|
+
const guest = isGuestChat(chatId);
|
|
400
|
+
const builtinTools = guest
|
|
401
|
+
? []
|
|
402
|
+
: config.nativeTools
|
|
403
|
+
? ALLOWED_TOOLS_CHAT.filter((t) => !NATIVE_REPLACED_BUILTINS.has(t))
|
|
404
|
+
: [...ALLOWED_TOOLS_CHAT];
|
|
405
|
+
|
|
406
|
+
const allServers = {
|
|
391
407
|
...buildMcpServers(chatId),
|
|
392
408
|
...buildPluginMcpServers(chatId),
|
|
393
409
|
};
|
|
410
|
+
const mcpServers = guest
|
|
411
|
+
? Object.fromEntries(
|
|
412
|
+
Object.entries(allServers).filter(
|
|
413
|
+
([name]) =>
|
|
414
|
+
isFrontendToolServerName(name) || isGuestPluginAllowed(name),
|
|
415
|
+
),
|
|
416
|
+
)
|
|
417
|
+
: allServers;
|
|
394
418
|
|
|
395
419
|
// Tool definitions render BEFORE the system prompt, so a set that shifts
|
|
396
420
|
// mid-session invalidates the system prompt and every cached message after
|
package/src/bootstrap.ts
CHANGED
|
@@ -144,6 +144,8 @@ export async function bootstrap(
|
|
|
144
144
|
disabledToolTags: config.disabledToolTags,
|
|
145
145
|
braveApiKey: config.braveApiKey,
|
|
146
146
|
nativeTools: config.nativeTools,
|
|
147
|
+
guestDmScope: config.guestDmScope,
|
|
148
|
+
adminUserId: config.adminUserId,
|
|
147
149
|
});
|
|
148
150
|
|
|
149
151
|
initWorkspace(config.workspace);
|
package/src/core/config/index.ts
CHANGED
|
@@ -608,6 +608,22 @@ const configSchema = z.object({
|
|
|
608
608
|
disabledTools: z.array(z.string()).optional(),
|
|
609
609
|
disabledToolTags: z.array(z.string()).optional(),
|
|
610
610
|
|
|
611
|
+
/**
|
|
612
|
+
* Conversation-only tool surface for DMs from anyone who isn't an
|
|
613
|
+
* operator. The admin's Telegram DM and any `operatorChats` keep
|
|
614
|
+
* everything; other DMs (Telegram user ids, `wa_dm_*`) get reply/react/
|
|
615
|
+
* history/stickers plus the `guestPlugins` servers — no shell, files,
|
|
616
|
+
* mail, devices, cron, memory, agents or cross-chat sends. Groups are
|
|
617
|
+
* unaffected. Off by default. See core/mcp-hub/guest-scope.ts.
|
|
618
|
+
*/
|
|
619
|
+
guestDmScope: z
|
|
620
|
+
.object({
|
|
621
|
+
enabled: z.boolean().default(false),
|
|
622
|
+
operatorChats: z.array(z.string()).default([]),
|
|
623
|
+
guestPlugins: z.array(z.string()).optional(),
|
|
624
|
+
})
|
|
625
|
+
.optional(),
|
|
626
|
+
|
|
611
627
|
/**
|
|
612
628
|
* Developer build flag. Gates dev-only affordances such as the
|
|
613
629
|
* `/update` self-update command. Off by default so packaged /
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guest DM tool scope.
|
|
3
|
+
*
|
|
4
|
+
* Anyone the operator lets DM the bot gets the same agent the operator
|
|
5
|
+
* gets: shell, files, mail, mesh devices, cron, memory, cross-chat sends.
|
|
6
|
+
* With `guestDmScope.enabled`, a DM from anyone who is not an operator gets a
|
|
7
|
+
* conversation-only surface instead, enforced here in the hub, so it holds
|
|
8
|
+
* for every backend that reaches tools through the hub:
|
|
9
|
+
*
|
|
10
|
+
* - Talon tools: an explicit allowlist (reply, react, edit/delete own
|
|
11
|
+
* messages, read this chat's history and media, look at stickers).
|
|
12
|
+
* Nothing that runs code, touches files, schedules, remembers, spawns
|
|
13
|
+
* agents, reaches devices or acts on another chat.
|
|
14
|
+
* - Parameters: a guest session may only target its own chat and may
|
|
15
|
+
* never attach a local file by path (`send(file_path=…)` would
|
|
16
|
+
* otherwise read any file the daemon can read and hand it over).
|
|
17
|
+
* - Plugin servers: only those named in `guestPlugins` (default: web
|
|
18
|
+
* search and the time/weather/currency extras).
|
|
19
|
+
*
|
|
20
|
+
* Groups are untouched: they already require the operator's membership,
|
|
21
|
+
* and their tool surface is a separate decision.
|
|
22
|
+
*
|
|
23
|
+
* Backend built-ins that live outside the hub (the Claude SDK's own
|
|
24
|
+
* Bash/Read/Write, Codex's shell) are the backend's job. The Claude SDK
|
|
25
|
+
* backend drops its built-ins for guest chats (see claude-sdk/options.ts).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type GuestDmScopeConfig = {
|
|
29
|
+
enabled?: boolean;
|
|
30
|
+
/** Chat ids that keep the full surface in addition to the admin's DM. */
|
|
31
|
+
operatorChats?: readonly string[];
|
|
32
|
+
/** Plugin/hub server names a guest DM may use. */
|
|
33
|
+
guestPlugins?: readonly string[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const DEFAULT_GUEST_PLUGINS: readonly string[] = [
|
|
37
|
+
"brave-search",
|
|
38
|
+
"extras-tools",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Talon tools a guest DM may see and call. Everything else is hidden. */
|
|
42
|
+
export const GUEST_TOOL_ALLOWLIST: ReadonlySet<string> = new Set([
|
|
43
|
+
"end_turn",
|
|
44
|
+
"send",
|
|
45
|
+
"react",
|
|
46
|
+
"edit_message",
|
|
47
|
+
"delete_message",
|
|
48
|
+
"stop_poll",
|
|
49
|
+
"get_chat_info",
|
|
50
|
+
"read_chat_history",
|
|
51
|
+
"search_chat_history",
|
|
52
|
+
"get_message_by_id",
|
|
53
|
+
"download_media",
|
|
54
|
+
"list_media",
|
|
55
|
+
"get_sticker_pack",
|
|
56
|
+
"download_sticker",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Parameters that name another chat. A guest may only name its own. */
|
|
60
|
+
const CHAT_TARGET_PARAMS = ["chat_id", "to_chat_id", "from_chat_id"] as const;
|
|
61
|
+
|
|
62
|
+
type ScopeState = {
|
|
63
|
+
enabled: boolean;
|
|
64
|
+
operators: Set<string>;
|
|
65
|
+
plugins: Set<string>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let state: ScopeState = {
|
|
69
|
+
enabled: false,
|
|
70
|
+
operators: new Set(),
|
|
71
|
+
plugins: new Set(DEFAULT_GUEST_PLUGINS),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Set at bootstrap (and on config reload). */
|
|
75
|
+
export function initGuestDmScope(
|
|
76
|
+
cfg: GuestDmScopeConfig | undefined,
|
|
77
|
+
adminUserId?: number,
|
|
78
|
+
): void {
|
|
79
|
+
const operators = new Set<string>(cfg?.operatorChats ?? []);
|
|
80
|
+
if (adminUserId) operators.add(String(adminUserId));
|
|
81
|
+
state = {
|
|
82
|
+
enabled: cfg?.enabled === true,
|
|
83
|
+
operators,
|
|
84
|
+
plugins: new Set(cfg?.guestPlugins ?? DEFAULT_GUEST_PLUGINS),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Is this chat id a one-to-one DM? Telegram DMs are the peer's positive
|
|
90
|
+
* user id; WhatsApp DMs are `wa_dm_<number>`. Anything else (groups,
|
|
91
|
+
* Discord, native, heartbeat) is not treated as a DM here.
|
|
92
|
+
*/
|
|
93
|
+
export function isDmChatId(chatId: string): boolean {
|
|
94
|
+
return /^\d+$/.test(chatId) || chatId.startsWith("wa_dm_");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Should this chat get the guest surface? */
|
|
98
|
+
export function isGuestChat(chatId: string): boolean {
|
|
99
|
+
if (!state.enabled) return false;
|
|
100
|
+
if (!isDmChatId(chatId)) return false;
|
|
101
|
+
return !state.operators.has(chatId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isGuestToolAllowed(name: string): boolean {
|
|
105
|
+
return GUEST_TOOL_ALLOWLIST.has(name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function isGuestPluginAllowed(serverName: string): boolean {
|
|
109
|
+
return state.plugins.has(serverName);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Why a guest call with these params must be refused, or null if it is
|
|
114
|
+
* fine. Checked on every guest tool call, after the allowlist.
|
|
115
|
+
*/
|
|
116
|
+
export function guestParamViolation(
|
|
117
|
+
chatId: string,
|
|
118
|
+
params: Record<string, unknown>,
|
|
119
|
+
): string | null {
|
|
120
|
+
for (const key of CHAT_TARGET_PARAMS) {
|
|
121
|
+
const v = params[key];
|
|
122
|
+
if (v !== undefined && v !== null && String(v) !== chatId) {
|
|
123
|
+
return `${key} must be this chat`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (params.file_path !== undefined) {
|
|
127
|
+
return "file_path is not available here; use a url or file_id";
|
|
128
|
+
}
|
|
129
|
+
const media = params.media;
|
|
130
|
+
if (Array.isArray(media)) {
|
|
131
|
+
for (const item of media) {
|
|
132
|
+
if (
|
|
133
|
+
item &&
|
|
134
|
+
typeof item === "object" &&
|
|
135
|
+
(item as Record<string, unknown>).file_path !== undefined
|
|
136
|
+
) {
|
|
137
|
+
return "file_path is not available here; use a url or file_id";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
@@ -53,6 +53,12 @@ import {
|
|
|
53
53
|
type ChildSpec,
|
|
54
54
|
} from "./children.js";
|
|
55
55
|
import type { ToolFrontend } from "../tools/types.js";
|
|
56
|
+
import {
|
|
57
|
+
initGuestDmScope,
|
|
58
|
+
isGuestChat,
|
|
59
|
+
isGuestPluginAllowed,
|
|
60
|
+
type GuestDmScopeConfig,
|
|
61
|
+
} from "./guest-scope.js";
|
|
56
62
|
|
|
57
63
|
export const HUB_PATH_PREFIX = "/mcp/";
|
|
58
64
|
|
|
@@ -64,6 +70,10 @@ export type HubConfig = {
|
|
|
64
70
|
braveApiKey?: string;
|
|
65
71
|
/** Surface the native tool set (bash/read/write/… + teleport). */
|
|
66
72
|
nativeTools?: boolean;
|
|
73
|
+
/** Conversation-only tool surface for non-operator DMs. */
|
|
74
|
+
guestDmScope?: GuestDmScopeConfig;
|
|
75
|
+
/** Operator's Telegram id — their DM always keeps the full surface. */
|
|
76
|
+
adminUserId?: number;
|
|
67
77
|
};
|
|
68
78
|
|
|
69
79
|
let hubConfig: HubConfig = {};
|
|
@@ -71,6 +81,7 @@ let hubConfig: HubConfig = {};
|
|
|
71
81
|
/** Set at bootstrap; safe to call again on config reload. */
|
|
72
82
|
export function initHub(config: HubConfig): void {
|
|
73
83
|
hubConfig = config;
|
|
84
|
+
initGuestDmScope(config.guestDmScope, config.adminUserId);
|
|
74
85
|
startChildReaper();
|
|
75
86
|
}
|
|
76
87
|
|
|
@@ -215,6 +226,7 @@ function buildServerFor(target: HubTarget, bridgeUrl: string) {
|
|
|
215
226
|
disabledTools: hubConfig.disabledTools,
|
|
216
227
|
disabledToolTags: hubConfig.disabledToolTags,
|
|
217
228
|
includeNativeTools: hubConfig.nativeTools,
|
|
229
|
+
guest: isGuestChat(target.chatId),
|
|
218
230
|
});
|
|
219
231
|
}
|
|
220
232
|
return buildProxyServer(target.serverName, () =>
|
|
@@ -327,6 +339,15 @@ export async function handleHubRequest(
|
|
|
327
339
|
return;
|
|
328
340
|
}
|
|
329
341
|
|
|
342
|
+
if (
|
|
343
|
+
target.kind === "plugin" &&
|
|
344
|
+
isGuestChat(target.chatId) &&
|
|
345
|
+
!isGuestPluginAllowed(target.serverName)
|
|
346
|
+
) {
|
|
347
|
+
jsonRpcError(res, 403, "Not available in this chat");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
330
351
|
const server = buildServerFor(target, bridgeUrl);
|
|
331
352
|
const transport = new StreamableHTTPServerTransport({
|
|
332
353
|
sessionIdGenerator: () => randomUUID(),
|
|
@@ -18,6 +18,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
18
18
|
import { composeTools } from "../tools/index.js";
|
|
19
19
|
import { createBridge, textResult } from "../tools/bridge.js";
|
|
20
20
|
import type { ToolFrontend, ToolTag } from "../tools/types.js";
|
|
21
|
+
import { guestParamViolation, isGuestToolAllowed } from "./guest-scope.js";
|
|
21
22
|
|
|
22
23
|
export const VALID_TOOL_FRONTENDS: ReadonlySet<string> = new Set([
|
|
23
24
|
"telegram",
|
|
@@ -37,6 +38,11 @@ export type TalonServerOptions = {
|
|
|
37
38
|
disabledToolTags?: readonly string[];
|
|
38
39
|
/** Expose the native tool set (replaces the SDK built-ins). */
|
|
39
40
|
includeNativeTools?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Guest DM: expose only the conversation allowlist and refuse calls that
|
|
43
|
+
* name another chat or a local file. See guest-scope.ts.
|
|
44
|
+
*/
|
|
45
|
+
guest?: boolean;
|
|
40
46
|
};
|
|
41
47
|
|
|
42
48
|
/** Build a Talon tool MCP server bound to one (frontend, chatId) pair. */
|
|
@@ -65,10 +71,21 @@ export function buildTalonToolServer(options: TalonServerOptions): McpServer {
|
|
|
65
71
|
if (endTurn) tools.push(endTurn);
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
const surface = options.guest
|
|
75
|
+
? tools.filter((t) => isGuestToolAllowed(t.name))
|
|
76
|
+
: tools;
|
|
77
|
+
|
|
78
|
+
for (const tool of surface) {
|
|
79
|
+
server.tool(tool.name, tool.description, tool.schema, async (params) => {
|
|
80
|
+
if (options.guest) {
|
|
81
|
+
const why = guestParamViolation(
|
|
82
|
+
options.chatId,
|
|
83
|
+
params as Record<string, unknown>,
|
|
84
|
+
);
|
|
85
|
+
if (why) return textResult(`Not available in this chat: ${why}.`);
|
|
86
|
+
}
|
|
87
|
+
return textResult(await tool.execute(params, bridge));
|
|
88
|
+
});
|
|
72
89
|
}
|
|
73
90
|
|
|
74
91
|
return server;
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* (rollout PR 6), so the operator can always ask what Talon remembers
|
|
8
8
|
* without the answer being able to change it.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* a
|
|
10
|
+
* Admin only, and only in a private chat. The store holds the operator's
|
|
11
|
+
* private notes — people, places, health, relationships — so a read of it
|
|
12
|
+
* is not a low-privilege action: in a group every member would see the
|
|
13
|
+
* reply, and anyone else who can reach the bot must not see it at all.
|
|
13
14
|
*
|
|
14
15
|
* Every line is model- or user-authored text reaching an HTML-parsed
|
|
15
16
|
* send, so it goes through `escapeHtml` before it is joined; the reply
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
import type { Bot, Context } from "grammy";
|
|
21
22
|
import { escapeHtml } from "../formatting.js";
|
|
22
23
|
import { replyHtmlChunked } from "../admin/chunked-reply.js";
|
|
24
|
+
import { isAuthorizedAdmin } from "./state.js";
|
|
23
25
|
import {
|
|
24
26
|
formatMemory,
|
|
25
27
|
getMemory,
|
|
@@ -36,11 +38,31 @@ const LIST_LIMIT = 15;
|
|
|
36
38
|
|
|
37
39
|
export function registerMemoryCommand(bot: Bot): void {
|
|
38
40
|
bot.command("memory", async (ctx: Context) => {
|
|
41
|
+
const verdict = memoryAccess(ctx);
|
|
42
|
+
if (verdict !== "ok") {
|
|
43
|
+
await ctx.reply(
|
|
44
|
+
verdict === "not-private"
|
|
45
|
+
? "Memory is private — ask me in a DM."
|
|
46
|
+
: "Not authorized.",
|
|
47
|
+
);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
39
50
|
const arg = (ctx.match ?? "").toString().trim();
|
|
40
51
|
await replyHtmlChunked(ctx, renderMemory(arg));
|
|
41
52
|
});
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Who may read memory here: the admin, in a private chat.
|
|
57
|
+
* Order matters — a non-admin in a group gets "not authorized", not
|
|
58
|
+
* a hint that a DM would work.
|
|
59
|
+
*/
|
|
60
|
+
function memoryAccess(ctx: Context): "ok" | "not-admin" | "not-private" {
|
|
61
|
+
if (!isAuthorizedAdmin(ctx)) return "not-admin";
|
|
62
|
+
if (ctx.chat?.type !== "private") return "not-private";
|
|
63
|
+
return "ok";
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
/** Route the argument to one of the four reads. Returns ready HTML. */
|
|
45
67
|
function renderMemory(arg: string): string {
|
|
46
68
|
const why = /^why\b\s*(.*)$/is.exec(arg);
|