talon-agent 5.32.1 → 5.33.1
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 +2 -2
- package/src/core/config/index.ts +15 -0
- package/src/core/secrets/redact.ts +276 -0
- package/src/frontend/discord/middleware.ts +30 -0
- package/src/frontend/native/surface/handlers.ts +31 -2
- package/src/frontend/teams/poll.ts +14 -0
- package/src/frontend/telegram/middleware.ts +42 -1
- package/src/frontend/whatsapp/messages/inbound.ts +63 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.33.1",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "The Falconry",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"@kilocode/sdk": "^7.2.22",
|
|
113
113
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
114
114
|
"@openai/agents": "^0.18.0",
|
|
115
|
-
"@openai/codex-sdk": "^0.
|
|
115
|
+
"@openai/codex-sdk": "^0.157.1",
|
|
116
116
|
"@opencode-ai/sdk": "^1.17.4",
|
|
117
117
|
"@playwright/mcp": "0.0.56",
|
|
118
118
|
"@types/cross-spawn": "^6.0.6",
|
package/src/core/config/index.ts
CHANGED
|
@@ -802,6 +802,21 @@ const configSchema = z.object({
|
|
|
802
802
|
.object({ allowPrivateNetworks: z.boolean().default(true) })
|
|
803
803
|
.strict()
|
|
804
804
|
.optional(),
|
|
805
|
+
/**
|
|
806
|
+
* Inbound credential redaction (docs/secret-drop.md). Before a user's
|
|
807
|
+
* message is saved or shown to the model, passwords, API keys, tokens and
|
|
808
|
+
* any value already in ~/.talon/secrets become `[REDACTED:<kind>]`, and
|
|
809
|
+
* the chat is told once to use /secret. `deleteOriginal` also deletes the
|
|
810
|
+
* platform message where the bot can: in DMs (default), everywhere, or
|
|
811
|
+
* never.
|
|
812
|
+
*/
|
|
813
|
+
redaction: z
|
|
814
|
+
.object({
|
|
815
|
+
enabled: z.boolean().default(true),
|
|
816
|
+
deleteOriginal: z.enum(["dm", "always", "never"]).default("dm"),
|
|
817
|
+
})
|
|
818
|
+
.strict()
|
|
819
|
+
.optional(),
|
|
805
820
|
/**
|
|
806
821
|
* The fetch ladder behind `fetch_url` (docs/fetch-ladder.md): when a page
|
|
807
822
|
* answers with a bot wall it retries through browser-TLS impersonation,
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound credential redaction — runs on a user's message before it is
|
|
3
|
+
* persisted (history DB, interaction logs, traces) or shown to the model.
|
|
4
|
+
*
|
|
5
|
+
* Three matchers, applied in order:
|
|
6
|
+
* 1. any value already stored in ~/.talon/secrets (exact substring);
|
|
7
|
+
* 2. well-known key/token shapes (sk-…, ghp_…, AKIA…, a PEM private key…);
|
|
8
|
+
* 3. a value next to a password word (`password: hunter2`, `pin 4821`).
|
|
9
|
+
*
|
|
10
|
+
* Each hit becomes `[REDACTED:<kind>]`. Conservative on purpose: prose that
|
|
11
|
+
* merely mentions a password ("I forgot my password", "pin the message")
|
|
12
|
+
* is left alone. A value after a bare "is" or space must look like a
|
|
13
|
+
* credential (a digit or symbol, at least six characters; a PIN is four to
|
|
14
|
+
* eight digits); after `:` or `=` any non-trivial token counts.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here logs, and nothing returned carries a matched value.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { dirs } from "../../util/paths.js";
|
|
22
|
+
|
|
23
|
+
type RedactionKind =
|
|
24
|
+
"stored" | "private-key" | "api-key" | "token" | "password" | "pin";
|
|
25
|
+
|
|
26
|
+
export interface RedactionResult {
|
|
27
|
+
text: string;
|
|
28
|
+
/** Kinds redacted, in first-seen order; empty when nothing matched. */
|
|
29
|
+
kinds: RedactionKind[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const marker = (kind: RedactionKind): string => `[REDACTED:${kind}]`;
|
|
33
|
+
|
|
34
|
+
// ── Known token shapes ──────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const TOKEN_PATTERNS: ReadonlyArray<{ kind: RedactionKind; re: RegExp }> = [
|
|
37
|
+
{
|
|
38
|
+
kind: "private-key",
|
|
39
|
+
re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
40
|
+
},
|
|
41
|
+
// OpenAI / Anthropic / OpenRouter style: sk-…, sk-ant-…, sk-proj-…
|
|
42
|
+
{ kind: "api-key", re: /\bsk-[A-Za-z0-9_-]{20,}/g },
|
|
43
|
+
// GitHub tokens
|
|
44
|
+
{
|
|
45
|
+
kind: "token",
|
|
46
|
+
re: /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/g,
|
|
47
|
+
},
|
|
48
|
+
// AWS access key id
|
|
49
|
+
{ kind: "api-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
50
|
+
// Google API key
|
|
51
|
+
{ kind: "api-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
52
|
+
// Stripe
|
|
53
|
+
{ kind: "api-key", re: /\b[rs]k_(?:live|test)_[0-9A-Za-z]{16,}/g },
|
|
54
|
+
// Slack
|
|
55
|
+
{ kind: "token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
56
|
+
// Telegram bot token
|
|
57
|
+
{ kind: "token", re: /\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/g },
|
|
58
|
+
// JWT
|
|
59
|
+
{ kind: "token", re: /\beyJ[\w-]{10,}\.eyJ[\w-]{10,}\.[\w-]{10,}/g },
|
|
60
|
+
// `api_key=…`, `token: …`, `Bearer …` — a labelled long opaque value
|
|
61
|
+
{
|
|
62
|
+
kind: "token",
|
|
63
|
+
re: /\b((?:api[_-]?key|access[_-]?token|auth[_-]?token|secret[_-]?key|client[_-]?secret|token|bearer)\s*(?:[:=]\s*|\s+))([A-Za-z0-9._~+/=-]{16,})/gi,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// ── Password-word values ────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
// Not bare "pass": "I'll pass on 2pm-ish" is prose.
|
|
70
|
+
const PASSWORD_WORDS = "password|passwd|passphrase|passcode|pwd|pw";
|
|
71
|
+
const PIN_WORDS = "pin code|pincode|pin";
|
|
72
|
+
const SEPARATOR = String.raw`(?:\s+(?:is|was)\s+|\s*[:=]\s*|\s+-\s+|\s+)`;
|
|
73
|
+
|
|
74
|
+
/** `<word> [is|:|=|-] <value>` — the separator is captured to judge the value. */
|
|
75
|
+
const PASSWORD_RE = new RegExp(
|
|
76
|
+
String.raw`\b((?:${PASSWORD_WORDS})\b${SEPARATOR})(\S+)`,
|
|
77
|
+
"gi",
|
|
78
|
+
);
|
|
79
|
+
const PIN_RE = new RegExp(
|
|
80
|
+
String.raw`\b((?:${PIN_WORDS})\b${SEPARATOR})(\S+)`,
|
|
81
|
+
"gi",
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/** Trailing sentence punctuation isn't part of the value. */
|
|
85
|
+
function splitTrailing(token: string): [string, string] {
|
|
86
|
+
const m = /^(.*?)([.,;!?)"']*)$/.exec(token);
|
|
87
|
+
return m ? [m[1] ?? "", m[2] ?? ""] : [token, ""];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function looksLikePassword(value: string, explicit: boolean): boolean {
|
|
91
|
+
if (value.startsWith("[REDACTED:")) return false;
|
|
92
|
+
if (explicit) return value.length >= 3;
|
|
93
|
+
if (value.length < 6) return false;
|
|
94
|
+
// A bare word after "is"/space is prose ("my password is wrong").
|
|
95
|
+
return /[0-9]/.test(value) || /[^A-Za-z0-9]/.test(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function replaceWordValues(
|
|
99
|
+
text: string,
|
|
100
|
+
re: RegExp,
|
|
101
|
+
kind: RedactionKind,
|
|
102
|
+
accept: (value: string, explicit: boolean) => boolean,
|
|
103
|
+
hit: (k: RedactionKind) => void,
|
|
104
|
+
): string {
|
|
105
|
+
return text.replace(re, (whole, lead: string, token: string) => {
|
|
106
|
+
const [value, trailing] = splitTrailing(token);
|
|
107
|
+
const explicit = /[:=]\s*$/.test(lead);
|
|
108
|
+
if (!value || !accept(value, explicit)) return whole;
|
|
109
|
+
hit(kind);
|
|
110
|
+
return `${lead}${marker(kind)}${trailing}`;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Stored secrets (cached on the folder's mtime) ───────────────────────
|
|
115
|
+
|
|
116
|
+
/** Shorter stored values would redact ordinary words and numbers. */
|
|
117
|
+
const MIN_STORED_LENGTH = 6;
|
|
118
|
+
|
|
119
|
+
let storedCache: { dir: string; stamp: number; values: string[] } | undefined;
|
|
120
|
+
|
|
121
|
+
function storedValues(dir: string): string[] {
|
|
122
|
+
let stamp: number;
|
|
123
|
+
try {
|
|
124
|
+
stamp = statSync(dir).mtimeMs;
|
|
125
|
+
} catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
if (storedCache && storedCache.dir === dir && storedCache.stamp === stamp) {
|
|
129
|
+
return storedCache.values;
|
|
130
|
+
}
|
|
131
|
+
const values: string[] = [];
|
|
132
|
+
try {
|
|
133
|
+
for (const name of readdirSync(dir)) {
|
|
134
|
+
if (name.startsWith(".")) continue;
|
|
135
|
+
try {
|
|
136
|
+
const v = readFileSync(join(dir, name), "utf8").trim();
|
|
137
|
+
if (v.length >= MIN_STORED_LENGTH && v.length <= 4096) values.push(v);
|
|
138
|
+
} catch {
|
|
139
|
+
/* unreadable entry — skip */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
// Longest first, so a value containing another is replaced whole.
|
|
146
|
+
values.sort((a, b) => b.length - a.length);
|
|
147
|
+
storedCache = { dir, stamp, values };
|
|
148
|
+
return values;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Drop the stored-value cache (tests; a write changes the mtime anyway). */
|
|
152
|
+
export function resetRedactionCache(): void {
|
|
153
|
+
storedCache = undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Entry point ─────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/** Redact credentials from one inbound message's text. */
|
|
159
|
+
export function redactInbound(
|
|
160
|
+
text: string,
|
|
161
|
+
opts: { secretsDir?: string } = {},
|
|
162
|
+
): RedactionResult {
|
|
163
|
+
if (!text) return { text, kinds: [] };
|
|
164
|
+
const kinds: RedactionKind[] = [];
|
|
165
|
+
const hit = (k: RedactionKind): void => {
|
|
166
|
+
if (!kinds.includes(k)) kinds.push(k);
|
|
167
|
+
};
|
|
168
|
+
let out = text;
|
|
169
|
+
|
|
170
|
+
for (const value of storedValues(opts.secretsDir ?? dirs.secrets)) {
|
|
171
|
+
if (out.includes(value)) {
|
|
172
|
+
out = out.split(value).join(marker("stored"));
|
|
173
|
+
hit("stored");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const { kind, re } of TOKEN_PATTERNS) {
|
|
178
|
+
out = out.replace(re, (whole: string, lead?: string, value?: string) => {
|
|
179
|
+
// Labelled pattern: keep the label, redact only the value.
|
|
180
|
+
if (typeof lead === "string" && typeof value === "string") {
|
|
181
|
+
if (value.startsWith("[REDACTED:") || !/[0-9]/.test(value))
|
|
182
|
+
return whole;
|
|
183
|
+
hit(kind);
|
|
184
|
+
return `${lead}${marker(kind)}`;
|
|
185
|
+
}
|
|
186
|
+
hit(kind);
|
|
187
|
+
return marker(kind);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
out = replaceWordValues(out, PASSWORD_RE, "password", looksLikePassword, hit);
|
|
192
|
+
out = replaceWordValues(
|
|
193
|
+
out,
|
|
194
|
+
PIN_RE,
|
|
195
|
+
"pin",
|
|
196
|
+
(v, explicit) => /^\d{4,8}$/.test(v) || (explicit && /^\d{3,12}$/.test(v)),
|
|
197
|
+
hit,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return { text: out, kinds };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The one-time nudge shown when something was redacted. */
|
|
204
|
+
function redactionNotice(
|
|
205
|
+
kinds: readonly RedactionKind[],
|
|
206
|
+
deleted: boolean,
|
|
207
|
+
): string {
|
|
208
|
+
const what = kinds.join(", ");
|
|
209
|
+
return (
|
|
210
|
+
`🔒 That message looked like it contained a credential (${what}). ` +
|
|
211
|
+
`I redacted it before saving or reading it` +
|
|
212
|
+
(deleted ? " and deleted the original message" : "") +
|
|
213
|
+
`. Next time, use /secret <name> to store it via a one-time private link.`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const noticed = new Set<string>();
|
|
218
|
+
|
|
219
|
+
/** True the first time per chat (per daemon run) — the nudge is said once. */
|
|
220
|
+
function shouldNotifyRedaction(chatKey: string): boolean {
|
|
221
|
+
if (noticed.has(chatKey)) return false;
|
|
222
|
+
noticed.add(chatKey);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Whether to delete the original platform message for this chat. */
|
|
227
|
+
export function shouldDeleteRedacted(
|
|
228
|
+
mode: "dm" | "always" | "never" | undefined,
|
|
229
|
+
isDm: boolean,
|
|
230
|
+
): boolean {
|
|
231
|
+
const m = mode ?? "dm";
|
|
232
|
+
return m === "always" || (m === "dm" && isDm);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface InboundRedaction {
|
|
236
|
+
text: string;
|
|
237
|
+
redacted: boolean;
|
|
238
|
+
/** Delete the platform's copy of the message (config + DM-ness). */
|
|
239
|
+
deleteOriginal: boolean;
|
|
240
|
+
/** The once-per-chat nudge to send, if any. */
|
|
241
|
+
notice?: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The one call a frontend makes per inbound message: redact `text` per the
|
|
246
|
+
* `redaction` config and decide on deletion and the nudge.
|
|
247
|
+
*/
|
|
248
|
+
export function applyInboundRedaction(
|
|
249
|
+
text: string,
|
|
250
|
+
ctx: {
|
|
251
|
+
chatKey: string;
|
|
252
|
+
isDm: boolean;
|
|
253
|
+
config?: { enabled?: boolean; deleteOriginal?: "dm" | "always" | "never" };
|
|
254
|
+
secretsDir?: string;
|
|
255
|
+
},
|
|
256
|
+
): InboundRedaction {
|
|
257
|
+
if (ctx.config?.enabled === false || !text) {
|
|
258
|
+
return { text, redacted: false, deleteOriginal: false };
|
|
259
|
+
}
|
|
260
|
+
const result = redactInbound(text, { secretsDir: ctx.secretsDir });
|
|
261
|
+
if (!result.kinds.length) {
|
|
262
|
+
return { text, redacted: false, deleteOriginal: false };
|
|
263
|
+
}
|
|
264
|
+
const deleteOriginal = shouldDeleteRedacted(
|
|
265
|
+
ctx.config?.deleteOriginal,
|
|
266
|
+
ctx.isDm,
|
|
267
|
+
);
|
|
268
|
+
return {
|
|
269
|
+
text: result.text,
|
|
270
|
+
redacted: true,
|
|
271
|
+
deleteOriginal,
|
|
272
|
+
...(shouldNotifyRedaction(ctx.chatKey)
|
|
273
|
+
? { notice: redactionNotice(result.kinds, deleteOriginal) }
|
|
274
|
+
: {}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
@@ -15,6 +15,8 @@ import { pushMessage } from "../../storage/history.js";
|
|
|
15
15
|
import { registerChat } from "../../core/background/pulse/pulse.js";
|
|
16
16
|
import { deriveNumericChatId } from "../../core/frontend-runtime/chat-id.js";
|
|
17
17
|
import { handleMessage, getSenderName } from "./handlers/index.js";
|
|
18
|
+
import { applyInboundRedaction } from "../../core/secrets/redact.js";
|
|
19
|
+
import { log } from "../../util/log.js";
|
|
18
20
|
|
|
19
21
|
export function registerMiddleware(client: Client, config: TalonConfig): void {
|
|
20
22
|
client.on("messageCreate", (msg: Message) => {
|
|
@@ -30,6 +32,8 @@ export function registerMiddleware(client: Client, config: TalonConfig): void {
|
|
|
30
32
|
|
|
31
33
|
if (isGroup) registerChat(chatId);
|
|
32
34
|
|
|
35
|
+
redactCredentials(msg, chatId, !isGroup, config);
|
|
36
|
+
|
|
33
37
|
// Push to history buffer — text or attachment placeholder
|
|
34
38
|
const numericMessageId = deriveNumericChatId(msg.id);
|
|
35
39
|
const senderId = deriveNumericChatId(msg.author.id);
|
|
@@ -80,3 +84,29 @@ export function registerMiddleware(client: Client, config: TalonConfig): void {
|
|
|
80
84
|
});
|
|
81
85
|
});
|
|
82
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Rewrite a credential out of `msg.content` before history, the handler or
|
|
90
|
+
* the model sees it (core/secrets/redact.ts), then delete the original and
|
|
91
|
+
* nudge toward /secret as configured. Delete/notice are best-effort.
|
|
92
|
+
*/
|
|
93
|
+
function redactCredentials(
|
|
94
|
+
msg: Message,
|
|
95
|
+
chatId: string,
|
|
96
|
+
isDm: boolean,
|
|
97
|
+
config: TalonConfig,
|
|
98
|
+
): void {
|
|
99
|
+
if (!msg.content) return;
|
|
100
|
+
const r = applyInboundRedaction(msg.content, {
|
|
101
|
+
chatKey: chatId,
|
|
102
|
+
isDm,
|
|
103
|
+
config: config.redaction,
|
|
104
|
+
});
|
|
105
|
+
if (!r.redacted) return;
|
|
106
|
+
msg.content = r.text;
|
|
107
|
+
log("discord", `[${chatId}] Redacted a credential from an inbound message`);
|
|
108
|
+
if (r.deleteOriginal && msg.deletable) msg.delete().catch(() => {});
|
|
109
|
+
if (r.notice && "send" in msg.channel) {
|
|
110
|
+
msg.channel.send(r.notice).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -47,6 +47,8 @@ import type { ClientAttachment } from "../protocol.js";
|
|
|
47
47
|
import { configSnapshot, applyConfigUpdate } from "./settings.js";
|
|
48
48
|
import { bridgeStatus, broadcastStatus } from "./status.js";
|
|
49
49
|
import { logWarn } from "../../../util/log.js";
|
|
50
|
+
import { applyInboundRedaction } from "../../../core/secrets/redact.js";
|
|
51
|
+
import { emitAssistant } from "../turn/emit.js";
|
|
50
52
|
import {
|
|
51
53
|
interruptTurn,
|
|
52
54
|
isBusy,
|
|
@@ -98,6 +100,31 @@ function resolveAttachments(
|
|
|
98
100
|
return resolved;
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Credentials out of a native message before it's emitted, persisted or
|
|
105
|
+
* sent to the model (core/secrets/redact.ts). The once-per-chat nudge is
|
|
106
|
+
* emitted after the caller has emitted the user's own message.
|
|
107
|
+
*/
|
|
108
|
+
function redactNativeText(
|
|
109
|
+
runtime: NativeRuntime,
|
|
110
|
+
chatId: string,
|
|
111
|
+
text: string,
|
|
112
|
+
): string {
|
|
113
|
+
const r = applyInboundRedaction(text, {
|
|
114
|
+
chatKey: chatId,
|
|
115
|
+
isDm: true,
|
|
116
|
+
config: { ...runtime.config.redaction, deleteOriginal: "never" },
|
|
117
|
+
});
|
|
118
|
+
const notice = r.notice;
|
|
119
|
+
if (notice) {
|
|
120
|
+
queueMicrotask(() => {
|
|
121
|
+
const entry = runtime.chats.get(chatId);
|
|
122
|
+
if (entry) emitAssistant(runtime, entry, notice);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return r.text;
|
|
126
|
+
}
|
|
127
|
+
|
|
101
128
|
export function buildBridgeHandlers(
|
|
102
129
|
runtime: NativeRuntime,
|
|
103
130
|
): BridgeServerHandlers {
|
|
@@ -114,7 +141,8 @@ export function buildBridgeHandlers(
|
|
|
114
141
|
// straight delegations to the read-only half of the store.
|
|
115
142
|
listMemory,
|
|
116
143
|
memoryWhy,
|
|
117
|
-
send: (id,
|
|
144
|
+
send: (id, rawText, opts, caller) => {
|
|
145
|
+
const text = redactNativeText(runtime, id, rawText);
|
|
118
146
|
const entry = chats.get(id) ?? chats.ensure(id);
|
|
119
147
|
// Resolve the client's references into the records this daemon minted
|
|
120
148
|
// at upload time — dropping anything it can't account for.
|
|
@@ -153,7 +181,8 @@ export function buildBridgeHandlers(
|
|
|
153
181
|
}
|
|
154
182
|
startTurn(runtime, entry, text, { attachments });
|
|
155
183
|
},
|
|
156
|
-
queueMessage: (id,
|
|
184
|
+
queueMessage: (id, rawText) => {
|
|
185
|
+
const text = redactNativeText(runtime, id, rawText);
|
|
157
186
|
// Edit/replace the queued follow-up's text, keeping whatever files were
|
|
158
187
|
// queued with it. Empty text cancels the whole follow-up, attachments
|
|
159
188
|
// included — the client's queue editor offers no other way to drop it.
|
|
@@ -11,6 +11,8 @@ import { handleSlashCommand } from "./commands.js";
|
|
|
11
11
|
import type { ChatMessage } from "./graph.js";
|
|
12
12
|
import type { TeamsRuntime } from "./runtime.js";
|
|
13
13
|
import { runTurn } from "./turn.js";
|
|
14
|
+
import { postToTeams } from "./actions.js";
|
|
15
|
+
import { applyInboundRedaction } from "../../core/secrets/redact.js";
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Messages arrive newest first; everything before `lastSeenId` is new. IDs
|
|
@@ -44,6 +46,18 @@ async function handleMessage(
|
|
|
44
46
|
): Promise<void> {
|
|
45
47
|
const talonChatId = `teams_chat_${msg.chatId}`;
|
|
46
48
|
if (await handleSlashCommand(runtime, msg, talonChatId)) return;
|
|
49
|
+
// Credentials out before logging, history and the model (core/secrets).
|
|
50
|
+
const redaction = applyInboundRedaction(msg.text, {
|
|
51
|
+
chatKey: talonChatId,
|
|
52
|
+
isDm: false,
|
|
53
|
+
config: { ...runtime.config.redaction, deleteOriginal: "never" },
|
|
54
|
+
});
|
|
55
|
+
if (redaction.redacted) {
|
|
56
|
+
msg = { ...msg, text: redaction.text };
|
|
57
|
+
if (redaction.notice) {
|
|
58
|
+
postToTeams(runtime.webhookUrl, redaction.notice).catch(() => {});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
47
61
|
|
|
48
62
|
log(
|
|
49
63
|
"teams",
|
|
@@ -13,11 +13,12 @@ import { pushMessage } from "../../storage/history.js";
|
|
|
13
13
|
import type { HistoryMessage } from "../../storage/repositories/history-repo.js";
|
|
14
14
|
import { allowChat, revokeChat } from "./userbot.js";
|
|
15
15
|
import { registerChat } from "../../core/background/pulse/pulse.js";
|
|
16
|
-
import { log } from "../../util/log.js";
|
|
16
|
+
import { log, logWarn } from "../../util/log.js";
|
|
17
17
|
import { getSenderName } from "./handlers/index.js";
|
|
18
18
|
import { noteUpdateId } from "./polling/update-offset.js";
|
|
19
19
|
import { noteInboundThread } from "./topics.js";
|
|
20
20
|
import { recordJoinRequest } from "./join-requests.js";
|
|
21
|
+
import { applyInboundRedaction } from "../../core/secrets/redact.js";
|
|
21
22
|
import {
|
|
22
23
|
handleTextMessage,
|
|
23
24
|
handlePhotoMessage,
|
|
@@ -104,6 +105,45 @@ function historyEntryFor(message: Message): HistoryEntry | undefined {
|
|
|
104
105
|
return undefined;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
// ── Credential redaction (before history, handlers and the model) ───────
|
|
109
|
+
// Rewrites the message's text/caption in place, so every later reader —
|
|
110
|
+
// history, interaction logs, traces, the prompt — sees only the
|
|
111
|
+
// `[REDACTED:<kind>]` form. See core/secrets/redact.ts.
|
|
112
|
+
export function makeRedactCredentials(config: TalonConfig) {
|
|
113
|
+
return async (
|
|
114
|
+
ctx: Filter<Context, "message">,
|
|
115
|
+
next: NextFunction,
|
|
116
|
+
): Promise<void> => {
|
|
117
|
+
const message = ctx.message as { text?: string; caption?: string };
|
|
118
|
+
const field = message.text !== undefined ? "text" : "caption";
|
|
119
|
+
const original = message[field];
|
|
120
|
+
if (original) {
|
|
121
|
+
const r = applyInboundRedaction(original, {
|
|
122
|
+
chatKey: String(ctx.chat.id),
|
|
123
|
+
isDm: ctx.chat.type === "private",
|
|
124
|
+
config: config.redaction,
|
|
125
|
+
});
|
|
126
|
+
if (r.redacted) {
|
|
127
|
+
message[field] = r.text;
|
|
128
|
+
log(
|
|
129
|
+
"bot",
|
|
130
|
+
`[${ctx.chat.id}] Redacted a credential from an inbound message`,
|
|
131
|
+
);
|
|
132
|
+
if (r.deleteOriginal) {
|
|
133
|
+
await ctx.deleteMessage().catch((err: unknown) => {
|
|
134
|
+
logWarn(
|
|
135
|
+
"bot",
|
|
136
|
+
`Could not delete a redacted message: ${err instanceof Error ? err.message : String(err)}`,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (r.notice) await ctx.reply(r.notice).catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return next();
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
107
147
|
// ── History capture (runs for ALL messages, before handlers) ─────────────
|
|
108
148
|
function captureHistory(
|
|
109
149
|
ctx: Filter<Context, "message">,
|
|
@@ -194,6 +234,7 @@ function registerMessageHandlers(bot: Bot, config: TalonConfig): void {
|
|
|
194
234
|
|
|
195
235
|
export function registerMiddleware(bot: Bot, config: TalonConfig): void {
|
|
196
236
|
bot.use(trackUpdateOffset);
|
|
237
|
+
bot.on("message", makeRedactCredentials(config));
|
|
197
238
|
bot.on("message", captureHistory);
|
|
198
239
|
bot.on("chat_join_request", cacheJoinRequest);
|
|
199
240
|
bot.on("my_chat_member", revokeOnRemoval);
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { isAddressedToSelf, isGroupAllowed } from "../access.js";
|
|
21
21
|
import { handleWhatsAppCommand, parseWhatsAppCommand } from "../commands.js";
|
|
22
22
|
import { sendText } from "../actions/send.js";
|
|
23
|
+
import { applyInboundRedaction } from "../../../core/secrets/redact.js";
|
|
23
24
|
import {
|
|
24
25
|
bareId,
|
|
25
26
|
canonicalId,
|
|
@@ -67,6 +68,65 @@ function extractText(msg: WAMessage): string {
|
|
|
67
68
|
);
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/** The text-bearing fields of an inbound message, as [holder, key] pairs. */
|
|
72
|
+
function textFields(msg: WAMessage): Array<[Record<string, unknown>, string]> {
|
|
73
|
+
const m = msg.message as Record<string, unknown> | null | undefined;
|
|
74
|
+
if (!m) return [];
|
|
75
|
+
const fields: Array<[Record<string, unknown>, string]> = [];
|
|
76
|
+
const add = (holder: unknown, key: string): void => {
|
|
77
|
+
if (
|
|
78
|
+
holder &&
|
|
79
|
+
typeof (holder as Record<string, unknown>)[key] === "string"
|
|
80
|
+
) {
|
|
81
|
+
fields.push([holder as Record<string, unknown>, key]);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
add(m, "conversation");
|
|
85
|
+
add(m.extendedTextMessage, "text");
|
|
86
|
+
add(m.imageMessage, "caption");
|
|
87
|
+
add(m.videoMessage, "caption");
|
|
88
|
+
add(m.documentMessage, "caption");
|
|
89
|
+
const wrapped = (
|
|
90
|
+
m.documentWithCaptionMessage as
|
|
91
|
+
{ message?: Record<string, unknown> } | undefined
|
|
92
|
+
)?.message;
|
|
93
|
+
add(wrapped?.documentMessage, "caption");
|
|
94
|
+
return fields;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Rewrite credentials out of the raw message in place (core/secrets/redact.ts),
|
|
99
|
+
* so the message store, history and the prompt only ever hold the redacted
|
|
100
|
+
* form; nudge toward /secret once. WhatsApp offers a bot no way to delete a
|
|
101
|
+
* user's message for both sides, so `deleteOriginal` doesn't apply here.
|
|
102
|
+
*/
|
|
103
|
+
function redactCredentials(
|
|
104
|
+
runtime: WhatsAppRuntime,
|
|
105
|
+
msg: WAMessage,
|
|
106
|
+
chat: WhatsAppChatInfo,
|
|
107
|
+
isGroup: boolean,
|
|
108
|
+
): void {
|
|
109
|
+
let notice: string | undefined;
|
|
110
|
+
for (const [holder, key] of textFields(msg)) {
|
|
111
|
+
const r = applyInboundRedaction(holder[key] as string, {
|
|
112
|
+
chatKey: chat.chatId,
|
|
113
|
+
isDm: !isGroup,
|
|
114
|
+
config: { ...runtime.config.redaction, deleteOriginal: "never" },
|
|
115
|
+
});
|
|
116
|
+
if (!r.redacted) continue;
|
|
117
|
+
holder[key] = r.text;
|
|
118
|
+
notice ??= r.notice;
|
|
119
|
+
log(
|
|
120
|
+
"whatsapp",
|
|
121
|
+
`[${chat.chatId}] Redacted a credential from an inbound message`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const sock = runtime.sock;
|
|
125
|
+
if (notice && sock) {
|
|
126
|
+
sendText({ sock, gateway: runtime.gateway }, chat, notice).catch(() => {});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
70
130
|
async function admitInbound(
|
|
71
131
|
runtime: WhatsAppRuntime,
|
|
72
132
|
msg: WAMessage,
|
|
@@ -131,7 +191,6 @@ async function recordInbound(
|
|
|
131
191
|
admitted: AdmittedMessage,
|
|
132
192
|
): Promise<RecordedMessage | null> {
|
|
133
193
|
const { jid, isGroup, identity } = admitted;
|
|
134
|
-
const text = extractText(msg).trim();
|
|
135
194
|
// Group chats key on the group JID; DMs key on the person, so the
|
|
136
195
|
// thread survives WhatsApp switching addressing form.
|
|
137
196
|
const chat = registerWhatsAppChat(
|
|
@@ -139,6 +198,9 @@ async function recordInbound(
|
|
|
139
198
|
undefined,
|
|
140
199
|
isGroup ? undefined : canonicalId(identity),
|
|
141
200
|
);
|
|
201
|
+
// Before the raw message is stored or its text read anywhere else.
|
|
202
|
+
redactCredentials(runtime, msg, chat, isGroup);
|
|
203
|
+
const text = extractText(msg).trim();
|
|
142
204
|
const senderName = msg.pushName || canonicalId(identity) || "user";
|
|
143
205
|
const msgId = rememberMessage({
|
|
144
206
|
key: msg.key,
|