Security
Teleton Agent applies defense-in-depth across 8 security domains: prompt injection defense, plugin isolation, MCP process hardening, tool scope enforcement, wallet and financial safety, secrets management, log redaction, and WebUI authentication.
Security Architecture Overview
Each domain operates independently so that a bypass in one layer does not compromise the others. The following table summarizes the 8 domains and the source files that implement them.
| Domain | Primary Defense | Source |
|---|---|---|
| Prompt Injection | sanitizeForPrompt() / sanitizeForContext() | src/utils/sanitize.ts |
| Plugin Isolation | Frozen SDK namespaces, deep-clone config, DB proxy | src/sdk/index.ts |
| MCP Servers | Env var blocklist, allowlist passthrough | src/agent/tools/mcp-loader.ts |
| Tool Scope System | 5-layer enforcement (register → filter → execute → DB override → module perms) | src/agent/tools/registry.ts |
| Wallet & Financial | 0o600 file mode, PAY_GAS_SEPARATELY, string-based decimals | src/ton/wallet-service.ts, src/ton/transfer.ts |
| Secrets Management | 0o700 secrets dir, per-plugin files, resolution order | src/sdk/secrets.ts |
| Log Redaction | Pino redact config covering 12 paths | src/utils/logger.ts |
| WebUI Auth | HttpOnly cookies, Bearer tokens, timing-safe compare | src/webui/server.ts, src/webui/middleware/auth.ts |
Prompt Injection Defense
Any text that enters the system prompt from external sources — user-provided names, RAG results, memory files, feed messages — is sanitized before insertion. There are two functions with different trade-offs.
sanitizeForPrompt()
Used for single-line values such as usernames, contact names, and short labels. It applies the most aggressive stripping and enforces a 128-character hard cap.
export function sanitizeForPrompt(text: string): string {
return text
.normalize("NFKC") // canonicalize homoglyphs (fullwidth, math variants, ligatures)
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") // control chars (keep \n \r \t)
.replace(/[\u00AD\u034F\u061C\u180E\u200B-\u200F\u2060-\u2064\uFEFF]/g, "") // zero-width/invisible chars
.replace(/[\uFE00-\uFE0F]/g, "") // variation selectors (emoji smuggling)
.replace(/[\u{E0000}-\u{E007F}]/gu, "") // Unicode Tag Block (invisible instruction injection)
.replace(/[\u{E0100}-\u{E01EF}]/gu, "") // extended variation selectors
.replace(/[\u202A-\u202E\u2066-\u2069]/g, "") // directional overrides
.replace(/[\r\n\u2028\u2029]+/g, " ") // all line breaks → space (including Unicode LS/PS)
.replace(/#{1,6}\s/g, "") // markdown headers
.replace(/<\/?[a-zA-Z_][^>]*>/g, "") // XML/HTML tags
.replace(/`{3,}/g, "`") // triple+ backticks → single (prevent code block injection)
.trim()
.slice(0, 128); // hard length cap for names
}Key threats addressed:
- Unicode Tag Block (U+E0000–U+E007F): invisible characters that can carry hidden instructions in LLM contexts.
- Directional overrides (U+202A–U+202E, U+2066–U+2069): can visually reverse text to deceive humans reviewing prompts.
- Zero-width characters (U+200B–U+200F etc.): used to hide tokens from tokenizers or inject separator sequences.
- Variation selectors (U+FE00–U+FE0F): emoji smuggling — encode arbitrary data in visually identical characters.
- Markdown headers and XML/HTML tags: could alter system prompt structure or inject fake tool results.
- Triple backticks: reduced to a single backtick to prevent code block escape sequences.
- NFKC normalization: collapses fullwidth Latin, mathematical variants, and ligatures to canonical forms before any rule is applied.
sanitizeForContext()
Used for multi-line content like RAG knowledge chunks, daily logs, and feed messages. It preserves line breaks (which are meaningful in context) and does not truncate, but applies the same Unicode and injection defenses.
export function sanitizeForContext(text: string): string {
return text
.normalize("NFKC")
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") // control chars (keep \n \r \t)
.replace(/[\u00AD\u034F\u061C\u180E\u200B-\u200F\u2060-\u2064\uFEFF]/g, "")
.replace(/[\uFE00-\uFE0F]/g, "")
.replace(/[\u{E0000}-\u{E007F}]/gu, "")
.replace(/[\u{E0100}-\u{E01EF}]/gu, "")
.replace(/[\u202A-\u202E\u2066-\u2069]/g, "")
.replace(/[\u2028\u2029]/g, "\n") // Unicode LS/PS → standard newline (not stripped)
.replace(/<\/?[a-zA-Z_][^>]*>/g, "") // XML/HTML tags
.replace(/`{3,}/g, "``") // triple+ backticks → double (prevent code block escape)
.trim();
}Differences from sanitizeForPrompt(): line breaks are preserved (Unicode LS/PS converted to \n rather than stripped), triple backticks are reduced to two (not one, since double backtick cannot open a code fence), and there is no length cap.
These functions are applied to: MEMORY.md chunks, daily log entries, RAG knowledge results, feed messages injected into context, and all MCP tool output before it is returned to the LLM.
Plugin Isolation
Plugins run in the same Node.js process as the agent but are given a restricted view of the runtime through a frozen, namespaced SDK object. Several mechanisms combine to prevent plugins from accessing each other's data or the agent's internal state.
Frozen SDK Namespaces
Every namespace exposed to a plugin is frozen with Object.freeze() before being handed to the plugin. The top-level PluginSDK object is also frozen. This prevents a plugin from monkey-patching SDK methods to intercept calls from other plugins.
const ton = Object.freeze(createTonSDK(log, safeDb));
const telegram = Object.freeze(createTelegramSDK(deps.bridge, log));
const secrets = Object.freeze(createSecretsSDK(opts.pluginName, opts.pluginConfig, log));
const storage = safeDb ? Object.freeze(createStorageSDK(safeDb)) : null;
const frozenLog = Object.freeze(log);
// config is deep-cloned AND frozen — mutations are silently dropped
const frozenConfig = Object.freeze(JSON.parse(JSON.stringify(opts.sanitizedConfig ?? {})));
const frozenPluginConfig = Object.freeze(JSON.parse(JSON.stringify(opts.pluginConfig ?? {})));
return Object.freeze(sdk); // top-level sdk is also frozenDeep-Cloned Configuration
Agent config is passed through JSON.parse(JSON.stringify(...)) before being exposed to a plugin. This breaks all object references, so a plugin cannot hold a live reference to the agent's configuration object and observe changes or inject values into future calls.
Database Proxy — Blocking ATTACH/DETACH
Plugins that declare a database receive a Proxy wrapping the real better-sqlite3 instance. The proxy intercepts exec() and prepare() calls and rejects any SQL containing ATTACH DATABASE or DETACH DATABASE. SQL comments are stripped before the check so they cannot be used to camouflage the keywords.
const BLOCKED_SQL_RE = /\b(ATTACH|DETACH)\s+DATABASE\b/i;
export function stripSqlComments(sql: string): string {
return sql
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments /* ... */
.replace(/--[^\n]*/g, " "); // line comments -- ...
}
function isSqlBlocked(sql: string): boolean {
return BLOCKED_SQL_RE.test(stripSqlComments(sql));
}
function createSafeDb(db: Database.Database): Database.Database {
return new Proxy(db, {
get(target, prop, receiver) {
if (prop === "exec") {
return (sql: string) => {
if (isSqlBlocked(sql)) throw new Error("ATTACH/DETACH DATABASE is not allowed in plugin context");
return target.exec(sql);
};
}
if (prop === "prepare") {
return (sql: string) => {
if (isSqlBlocked(sql)) throw new Error("ATTACH/DETACH DATABASE is not allowed in plugin context");
return target.prepare(sql);
};
}
return typeof value === "function" ? value.bind(target) : value;
},
});
}Each plugin gets its own SQLite database file stored at ~/.teleton/plugins/data/<plugin-name>.db. The proxy then prevents a plugin from using ATTACH DATABASE to bridge into another plugin's file or the main memory.db.
Semver Version Check — Fail-Closed
The semverSatisfies() function used to validate a plugin's declared sdkVersion range fails closed: if either the current version string or the range string cannot be parsed, the function returns false and the plugin is rejected. Malformed version strings never result in an accidental pass.
export function semverSatisfies(current: string, range: string): boolean {
const cur = parseSemver(current);
if (!cur) {
sdkLog.warn(`[SDK] Could not parse current version "${current}", rejecting`);
return false; // fail-closed
}
if (range.startsWith(">=")) {
const req = parseSemver(range.slice(2));
if (!req) {
sdkLog.warn(`[SDK] Malformed sdkVersion range "${range}", rejecting`);
return false; // fail-closed
}
return semverGte(cur, req);
}
// ... similar for ^ and exact match
}MCP Server Security
MCP servers launched via stdio (child processes) receive a heavily restricted environment. Only a small allowlist of environment variables is forwarded; a separate blocklist prevents the most dangerous variables from being passed even if explicitly configured in config.yaml.
Environment Variable Controls
// Only forward essential environment vars to child processes
const safeEnv: Record<string, string> = {};
for (const key of ["PATH", "HOME", "NODE_PATH", "LANG", "TERM"]) {
if (process.env[key]) safeEnv[key] = process.env[key]!;
}
// Block dangerous env vars that could enable code injection
const BLOCKED_ENV_KEYS = new Set([
"LD_PRELOAD",
"NODE_OPTIONS",
"LD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES",
"ELECTRON_RUN_AS_NODE",
]);
const filteredEnv: Record<string, string> = {};
for (const [k, v] of Object.entries(serverConfig.env ?? {})) {
if (BLOCKED_ENV_KEYS.has(k.toUpperCase())) {
log.warn({ key: k, server: name }, "Blocked dangerous env var for MCP server");
} else {
filteredEnv[k] = v;
}
}
transport = new StdioClientTransport({
command, args,
env: { ...safeEnv, ...filteredEnv },
stderr: "pipe",
});The blocklist rationale:
LD_PRELOAD/LD_LIBRARY_PATH: allow injecting arbitrary shared libraries into the child process on Linux.DYLD_INSERT_LIBRARIES: equivalent on macOS.NODE_OPTIONS: can set--requireto load arbitrary code, override V8 flags, or expose the debugger.ELECTRON_RUN_AS_NODE: causes Electron binaries to behave as a Node.js runtime with elevated permissions.
Transport: Streamable HTTP with SSE Fallback
For URL-based MCP servers, Teleton defaults to the modern Streamable HTTP transport (StreamableHTTPClientTransport). If the server rejects it, the loader automatically retries with the legacy SSE transport. This ensures compatibility while preferring the more secure, connection-oriented protocol.
MCP Tool Output Sanitization
All text returned by MCP tools is passed through sanitizeForContext() before being returned to the LLM, preventing a compromised MCP server from injecting prompt content.
const text = extractText(result.content as Array<{ type: string; text?: string }>);
return { success: true, data: sanitizeForContext(text) };Tool Scope System
Every tool has a scope that controls in which context it may be called. Enforcement happens at 5 independent layers, so no single bypass grants full access.
Scope Values
| Scope | Availability |
|---|---|
always | Available in all contexts (DM, group, admin). Default when no scope is declared. |
dm-only | Available only in direct message conversations, never in group chats. |
group-only | Available only in group chats, never in DMs. |
admin-only | Restricted to users listed in telegram.admin_ids. |
Layer 1: Registration with Scope
Each tool category declares its tools with an explicit scope in its index.ts. The registry stores this as the default scope at registration time.
Layer 2: getForContext() Filtering
Before the LLM sees the tool list for any given message, the registry filters out tools that are incompatible with the current context (DM vs group, admin vs non-admin). This means scoped tools are never exposed to the LLM in the wrong context — they do not appear as available options at all.
getForContext(isGroup: boolean, toolLimit: number | null, chatId?: string, isAdmin?: boolean): PiAiTool[] {
const excluded = isGroup ? "dm-only" : "group-only";
const filtered = Array.from(this.tools.values())
.filter((rt) => {
if (!this.isToolEnabled(rt.tool.name)) return false;
const effectiveScope = this.getEffectiveScope(rt.tool.name);
if (effectiveScope === excluded) return false;
if (effectiveScope === "admin-only" && !isAdmin) return false;
// ... module-level permission check
return true;
})
.map((rt) => rt.tool);
// ...
}Layer 3: execute() Validation
Even if a tool somehow reached the LLM, the registry re-checks scope at execution time and returns an error result instead of running the tool.
const scope = this.getEffectiveScope(toolCall.name);
if (scope === "dm-only" && context.isGroup) {
return { success: false, error: `Tool "${toolCall.name}" is not available in group chats` };
}
if (scope === "group-only" && !context.isGroup) {
return { success: false, error: `Tool "${toolCall.name}" is only available in group chats` };
}
if (scope === "admin-only") {
const isAdmin = context.config?.telegram.admin_ids.includes(context.senderId) ?? false;
if (!isAdmin) {
return { success: false, error: `Tool "${toolCall.name}" is restricted to admin users` };
}
}Layer 4: Runtime DB Overrides via getEffectiveScope()
The tool_config table in memory.db can override the scope of any tool at runtime without restarting. When a DB entry exists, it takes priority over the code-defined default. The getEffectiveScope() private method is called at both filter time and execution time, so the DB override applies consistently at all enforcement points.
private getEffectiveScope(toolName: string): ToolScope {
const config = this.toolConfigs.get(toolName);
if (config?.scope !== null && config?.scope !== undefined) {
return config.scope; // DB override wins
}
return this.scopes.get(toolName) ?? "always"; // fall back to registered default
}Layer 5: Module-Level Permissions (Per-Group Toggles)
In group chats, each tool module (e.g., telegram, ton, dns) can be set to enabled, admin, or disabled on a per-group basis via ModulePermissions. This check runs inside both getForContext() and execute(), providing a fifth independent barrier.
Wallet and Financial Security
Wallet File Permissions
The wallet file at ~/.teleton/wallet.json is written with mode 0o600 (owner read/write only). This applies both on initial creation and on every subsequent save, including imports.
writeFileSync(WALLET_FILE, JSON.stringify(wallet, null, 2), {
encoding: "utf-8",
mode: 0o600, // owner read/write only
});On load, the wallet is validated: mnemonic must be present, must be an array, and must contain exactly 24 words. Invalid files return null rather than throwing, and the error is logged for diagnosis.
W5R1 Wallet Contract
All generated and imported wallets use the WalletContractV5R1 contract type, which is the current recommended version of the TON wallet standard.
SendMode: PAY_GAS_SEPARATELY Only
All TON transfers use SendMode.PAY_GAS_SEPARATELY. The IGNORE_ERRORS flag is deliberately not set. This means that if a transfer fails for any reason, the transaction is not silently discarded — the failure is surfaced and can be handled by the caller.
await contract.sendTransfer({
seqno,
secretKey: keyPair.secretKey,
sendMode: SendMode.PAY_GAS_SEPARATELY, // IGNORE_ERRORS is NOT set
messages: [
internal({
to: recipientAddress,
value: toNano(amount),
body: comment,
bounce,
}),
],
});String-Based Decimal Conversion for Jetton Amounts
When converting a floating-point Jetton amount to its on-chain integer representation, the conversion uses string arithmetic rather than Math.floor(amount * 10**decimals). Floating-point multiplication introduces precision errors for large token amounts or tokens with many decimal places. The string-based approach is exact.
const amountStr = amount.toFixed(decimals);
const [whole, frac = ""] = amountStr.split(".");
// Pad or truncate the fractional part to exactly `decimals` digits, then concatenate
const amountInUnits = BigInt(whole + (frac + "0".repeat(decimals)).slice(0, decimals));Example: for an amount of 1.5 with 9 decimals, toFixed(9) produces "1.500000000". Splitting and concatenating gives "1500000000", which is then converted to BigInt — no floating-point rounding involved.
Secrets Management
Plugin secrets (API keys, tokens, credentials) are stored separately from the main config and from the plugin's source code. The secrets directory is created with restrictive permissions and each plugin's secrets file is similarly restricted.
Directory and File Permissions
const SECRETS_DIR = join(TELETON_ROOT, "plugins", "data");
// ~/.teleton/plugins/data/
export function writePluginSecret(pluginName: string, key: string, value: string): void {
mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 }); // dir: owner rwx only
const filePath = getSecretsPath(pluginName);
const existing = readSecretsFile(pluginName);
existing[key] = value;
writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 }); // file: owner rw only
}Resolution Order
When a plugin calls sdk.secrets.get("KEY"), the resolution order is:
- Environment variable —
PLUGINNAME_KEY(uppercase, hyphens to underscores). Highest priority; suitable for Docker/CI environments. - Secrets store file —
~/.teleton/plugins/data/<plugin-name>.secrets.json. Set via the/plugin set <name> <key> <value>admin command over Telegram. - pluginConfig from config.yaml — legacy fallback; the key is read from the plugin's section in the YAML config.
function get(key: string): string | undefined {
// 1. Environment variable (highest priority — Docker/CI)
const envKey = `${envPrefix}_${key.toUpperCase()}`;
const envValue = process.env[envKey];
if (envValue) return envValue;
// 2. Persisted secrets store (set via /plugin set)
const stored = readSecretsFile(pluginName);
if (key in stored && stored[key]) return stored[key];
// 3. pluginConfig from config.yaml (legacy/manual)
const configValue = pluginConfig[key];
if (configValue !== undefined && configValue !== null) return String(configValue);
return undefined;
}The sdk.secrets.require(key) variant throws a PluginSDKError with SECRET_NOT_FOUND code if none of the three sources provide a value, including the admin command to use for remediation in the error message.
Log Redaction
Teleton uses Pino's built-in redact feature to prevent sensitive values from appearing in log output. The redact configuration covers both top-level fields and nested variants using the *.field wildcard syntax.
const rootLogger = pino(
{
level: initialLevel,
timestamp: pino.stdTimeFunctions.isoTime,
base: null,
redact: {
paths: [
"apiKey", "api_key", "password", "secret",
"token", "mnemonic",
"*.apiKey", "*.api_key", "*.password", "*.secret",
"*.token", "*.mnemonic",
],
censor: "[REDACTED]",
},
},
multiStream
);Any structured log call that includes one of these field names — whether at the top level of the log object or nested one level deep — will have its value replaced with [REDACTED] before the entry reaches stdout or the WebUI SSE stream. This covers the Pino JSON transport (raw output) and the pino-pretty formatted terminal output equally.
The 12 redacted paths are: apiKey, api_key, password, secret, token, mnemonic, and the *. nested variants of each.
URL Sanitization
When Markdown text is converted to Telegram HTML (for message sending), URLs in link syntax [text](url) are passed through sanitizeUrl() before being placed in href attributes.
function sanitizeUrl(url: string): string {
const trimmed = url.trim().toLowerCase();
if (/^(javascript|data|vbscript|file):/i.test(trimmed)) return "#";
return url.replace(/"/g, """);
}
// Applied in link conversion:
html = html.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_, text, url) => `<a href="${sanitizeUrl(url)}">${text}</a>`
);Two defenses are applied:
- Dangerous scheme blocking: URLs with
javascript:,data:,vbscript:, orfile:schemes are replaced with#. - Double-quote escaping: any
"character in the URL is HTML-entity-encoded to", preventing an attacker from closing thehref="..."attribute and injecting additional HTML attributes or tags.
Workspace Sandboxing
When the SDK's sendStory() method is called with a media file path, the path is resolved and validated against an allowlist of directories before the file is read. This prevents path traversal and symlink attacks.
const { resolve, normalize } = await import("path");
const { homedir } = await import("os");
const { realpathSync } = await import("fs");
// realpathSync resolves all symlinks before checking the allowlist
const filePath = realpathSync(resolve(normalize(mediaPath)));
const home = homedir();
const teletonWorkspace = `${home}/.teleton/workspace/`;
const allowedPrefixes = [
"/tmp/",
`${home}/Downloads/`,
`${home}/Pictures/`,
`${home}/Videos/`,
`${teletonWorkspace}uploads/`,
`${teletonWorkspace}downloads/`,
`${teletonWorkspace}memes/`,
];
if (!allowedPrefixes.some((p) => filePath.startsWith(p))) {
throw new PluginSDKError(
"sendStory: media path must be within /tmp, Downloads, Pictures, or Videos",
"OPERATION_FAILED"
);
}The critical detail is that realpathSync() is called before the prefix check. This resolves all symbolic links in the path, so a symlink pointing from /tmp/malicious.mp4 to ~/.teleton/wallet.json would be expanded to the real target path and then fail the allowlist check. A naive path.normalize()-only approach would not catch this.
WebUI Authentication
The optional WebUI dashboard (disabled by default, binding to 127.0.0.1 only) uses a layered authentication scheme for API routes.
Auth Token
A 32-byte cryptographically random token is generated at startup using randomBytes(32).toString("base64url") if no auth_token is set in config. The token is logged in masked form (abcd...wxyz) for initial access.
Token Verification — Timing-Safe
All token comparisons use timingSafeEqual() from Node's node:crypto module to prevent timing side-channel attacks. Tokens of different lengths are rejected before the comparison (required by timingSafeEqual) but the rejection is not distinguishable from a failed comparison by timing.
export function safeCompare(a: string, b: string): boolean {
if (!a || !b) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}Authentication Precedence
The /api/* middleware accepts credentials in three forms, checked in order:
- HttpOnly session cookie (
teleton_session) — set after initial login;httpOnly: true,sameSite: "Strict", 7-day max age. The browser cannot read this cookie via JavaScript. - Authorization header —
Bearer <token>for API/CLI access. - Query parameter —
?token=<token>for backward compatibility (e.g., initial browser link from CLI output).
Auth-Exempt Routes
The following routes require no authentication and are intentionally public:
GET /health— liveness checkGET /auth/exchange— one-time token exchange that sets the HttpOnly cookiePOST /auth/login— password-style login that returns a cookiePOST /auth/logout— clears the session cookieGET /auth/check— returns whether the current cookie is valid
CORS and Security Headers
CORS is configured to allow only origins listed in webui.cors_origins (default: http://localhost:5173 and http://localhost:7777). All responses include X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Referrer-Policy: strict-origin-when-cross-origin. The body size is capped at 2 MB to limit oversized payload attacks.
Exec Audit Log
All command executions performed by the agent (via workspace and shell tools) are recorded in the exec_audit table in memory.db. This provides a durable, queryable history of every command, its outcome, and who requested it.
CREATE TABLE IF NOT EXISTS exec_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL DEFAULT (unixepoch()),
user_id INTEGER NOT NULL,
username TEXT,
tool TEXT NOT NULL,
command TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running'
CHECK(status IN ('running', 'success', 'failed', 'timeout', 'killed')),
exit_code INTEGER,
signal TEXT,
duration_ms INTEGER,
stdout TEXT,
stderr TEXT,
truncated INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_exec_audit_timestamp ON exec_audit(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_exec_audit_user ON exec_audit(user_id);| Column | Type | Description |
|---|---|---|
user_id | INTEGER | Telegram user ID of the person who triggered the command. |
username | TEXT | Telegram username at time of execution (nullable). |
tool | TEXT | Name of the tool that executed the command (e.g., workspace_exec). |
command | TEXT | Full command string as executed. |
status | TEXT | One of: running, success, failed, timeout, killed. |
exit_code | INTEGER | Process exit code (nullable while running). |
signal | TEXT | Signal name if the process was killed (e.g., SIGKILL). |
duration_ms | INTEGER | Wall-clock execution time in milliseconds. |
stdout / stderr | TEXT | Captured output (may be truncated; see truncated flag). |
truncated | INTEGER | 1 if stdout/stderr were truncated to fit storage limits. |
The table is indexed on timestamp DESC for recency queries and on user_id for per-user activity audits. It was introduced in schema migration 1.12.0.