Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion src/agent/tools/exec/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,39 @@ export const execRunTool: Tool = {
}),
};

/**
* Shell metacharacters that could be used for command injection.
* If a command contains any of these, it is rejected in allowlist mode
* unless the allowlist entry itself contains the same metacharacter
* (indicating the owner explicitly intended a compound command).
*/
const SHELL_METACHARACTERS = /[;|&`$(){}[\]<>!#*?\\]/;

export function isCommandAllowed(command: string, commandAllowlist: string[]): boolean {
const trimmed = command.trim();

// Reject empty commands
if (!trimmed) return false;

return commandAllowlist.some((pattern) => {
const p = pattern.trim();
return trimmed === p || trimmed.startsWith(p + " ");
if (!p) return false;

// Exact match — always allowed
if (trimmed === p) return true;

// Prefix match: the command must start with the pattern followed by
// a space, AND the remainder must not contain shell metacharacters
// that would enable injection (e.g., "ls; rm -rf /").
if (trimmed.startsWith(p + " ")) {
const remainder = trimmed.slice(p.length + 1);
// If the pattern itself contains metacharacters, trust the owner's
// allowlist entry. Otherwise, reject if remainder has metacharacters.
if (SHELL_METACHARACTERS.test(p)) return true;
return !SHELL_METACHARACTERS.test(remainder);
}

return false;
});
}

Expand Down
8 changes: 6 additions & 2 deletions src/agent/tools/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ export function runCommand(
let timedOut = false;
let resolved = false;

// Use security-provided env whitelist, or fall back to sanitized env
// SECURITY: Environment variable handling:
// - If env_whitelist is configured (non-empty), use it exclusively (allowlist).
// - Otherwise, use the name-pattern blacklist sanitizer as a fallback.
// The blacklist filters out common secret patterns (API_KEY, TOKEN, etc.)
// but is not exhaustive — prefer explicit env_whitelist for production.
const env = securityEnv ?? sanitizeEnv(process.env);

// On Linux, use prctl-pdeathsig helper if available so the kernel kills
Expand Down Expand Up @@ -203,7 +207,7 @@ export function runCommand(
/** Ensure the sandbox directory exists on disk. */
export function ensureSandboxDir(sandboxDir: string): void {
if (!fs.existsSync(sandboxDir)) {
fs.mkdirSync(sandboxDir, { recursive: true, mode: 0o755 });
fs.mkdirSync(sandboxDir, { recursive: true, mode: 0o700 });
}
}

Expand Down
40 changes: 33 additions & 7 deletions src/agent/tools/telegram/media/download-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import type { Tool, ToolExecutor, ToolResult } from "../../types.js";
import {
validateWritePath,
extensionToFileType,
sanitizeFilename,
WorkspaceSecurityError,
} from "../../../../workspace/index.js";
import { getErrorMessage } from "../../../../utils/errors.js";
import { createLogger } from "../../../../utils/logger.js";

/** Maximum allowed media download size (50 MB) */
const MAX_MEDIA_DOWNLOAD_SIZE = 50 * 1024 * 1024;

const log = createLogger("Tools");

/**
Expand Down Expand Up @@ -128,24 +132,37 @@ export const telegramDownloadMediaExecutor: ToolExecutor<DownloadMediaParams> =
mediaType = "gif";
}

// SECURITY: Reject filenames containing path separators or traversal
// sequences. An attacker could use "../../SOUL.md" to escape the
// downloads/ directory. (H-1)
if (filename && (/[/\\]/.test(filename) || filename.includes(".."))) {
return {
success: false,
error: `Invalid filename: path separators and '..' sequences are not allowed.`,
};
}

// Sanitize user-provided filename to remove dangerous characters (M-4)
const safeFilename = filename ? sanitizeFilename(filename) : undefined;

// Validate custom filename extension matches media type (security check)
if (filename) {
const providedExt = extname(filename).toLowerCase();
if (safeFilename) {
const providedExt = extname(safeFilename).toLowerCase();
const expectedExt = extension.toLowerCase();

// Case 1: Media has extension but filename doesn't
if (expectedExt && !providedExt) {
return {
success: false,
error: `Missing extension: filename '${filename}' must have extension '${expectedExt}' for ${mediaType}`,
error: `Missing extension: filename '${safeFilename}' must have extension '${expectedExt}' for ${mediaType}`,
};
}

// Case 2: Filename has extension but media doesn't expect one
if (providedExt && !expectedExt) {
return {
success: false,
error: `Unexpected extension: filename '${filename}' has extension '${providedExt}' but ${mediaType} does not require one`,
error: `Unexpected extension: filename '${safeFilename}' has extension '${providedExt}' but ${mediaType} does not require one`,
};
}

Expand All @@ -160,8 +177,9 @@ export const telegramDownloadMediaExecutor: ToolExecutor<DownloadMediaParams> =
}
}

// Generate filename
const finalFilename = filename || `${chatId}_${messageId}_${Date.now()}${extension}`;
// Generate filename (sanitize generated name too for safety)
const generatedName = `${chatId}_${messageId}_${Date.now()}${extension}`;
const finalFilename = safeFilename || sanitizeFilename(generatedName);

// Validate workspace path for downloads/
const downloadPath = `downloads/${finalFilename}`;
Expand Down Expand Up @@ -189,7 +207,15 @@ export const telegramDownloadMediaExecutor: ToolExecutor<DownloadMediaParams> =
};
}

// Save to file
// SECURITY: Enforce size limit to prevent memory exhaustion (H-2)
if (buffer.length > MAX_MEDIA_DOWNLOAD_SIZE) {
return {
success: false,
error: `Media too large: ${buffer.length} bytes exceeds maximum of ${MAX_MEDIA_DOWNLOAD_SIZE} bytes (${Math.round(MAX_MEDIA_DOWNLOAD_SIZE / 1024 / 1024)} MB)`,
};
}

// Save to file with restrictive permissions (H-3)
writeFileSync(validatedPath.absolutePath, buffer, { mode: 0o600 });

return {
Expand Down
94 changes: 91 additions & 3 deletions src/sdk/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,99 @@
* 3. pluginConfig (config.yaml) — legacy/manual
*
* Secrets store: ~/.teleton/plugins/data/<plugin-name>.secrets.json
* Secrets are encrypted at rest with AES-256-GCM using TELETON_SECRETS_KEY
* (falls back to TELETON_WALLET_KEY if not set).
*/

import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from "path";
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
import { TELETON_ROOT } from "../workspace/paths.js";
import { PluginSDKError } from "@teleton-agent/sdk";
import type { SecretsSDK, PluginLogger } from "@teleton-agent/sdk";

const SECRETS_DIR = join(TELETON_ROOT, "plugins", "data");

// ─── Encryption helpers ──────────────────────────────────────────────

interface EncryptedFile {
encrypted: true;
iv: string;
tag: string;
ciphertext: string;
}

/**
* Resolve the encryption key for secrets.
* Prefers TELETON_SECRETS_KEY, falls back to TELETON_WALLET_KEY.
* Returns null only if neither is configured (legacy unencrypted mode).
*/
function resolveSecretsEncryptionKey(): Buffer | null {
const envKey = process.env.TELETON_SECRETS_KEY || process.env.TELETON_WALLET_KEY;
if (!envKey) return null;
if (envKey.length !== 64 || !/^[0-9a-fA-F]+$/.test(envKey)) {
throw new Error(
"TELETON_SECRETS_KEY / TELETON_WALLET_KEY must be a 64-character hex string (32 bytes)."
);
}
return Buffer.from(envKey, "hex");
}

function encryptJson(data: Record<string, string>, key: Buffer): EncryptedFile {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const plaintext = JSON.stringify(data);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return {
encrypted: true,
iv: iv.toString("hex"),
tag: tag.toString("hex"),
ciphertext: encrypted.toString("hex"),
};
}

function decryptJson(file: EncryptedFile, key: Buffer): Record<string, string> {
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(file.iv, "hex"));
decipher.setAuthTag(Buffer.from(file.tag, "hex"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(file.ciphertext, "hex")),
decipher.final(),
]);
return JSON.parse(decrypted.toString("utf8")) as Record<string, string>;
}

// ─── File I/O ─────────────────────────────────────────────────────────

function getSecretsPath(pluginName: string): string {
return join(SECRETS_DIR, `${pluginName}.secrets.json`);
}

/** Read persisted secrets from the JSON file */
/** Read persisted secrets from the JSON file (handles both encrypted and legacy plaintext) */
function readSecretsFile(pluginName: string): Record<string, string> {
const filePath = getSecretsPath(pluginName);
try {
if (!existsSync(filePath)) return {};
const raw = readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};

// Encrypted format
if (parsed.encrypted === true) {
const key = resolveSecretsEncryptionKey();
if (!key) {
// Key not configured — cannot decrypt. Return empty and log warning.
return {};
}
try {
return decryptJson(parsed as EncryptedFile, key);
} catch {
// Decryption failed — wrong key or corrupted file
return {};
}
}

// Legacy plaintext format — return as-is (will be re-encrypted on next write)
return parsed as Record<string, string>;
} catch {
return {};
Expand All @@ -38,13 +109,23 @@ function readSecretsFile(pluginName: string): Record<string, string> {
/**
* Write a secret to the persisted secrets file.
* Used by admin commands (/plugin set).
* Secrets are encrypted at rest when an encryption key is available.
*/
export function writePluginSecret(pluginName: string, key: string, value: string): void {
mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 });
const filePath = getSecretsPath(pluginName);
const existing = readSecretsFile(pluginName);
existing[key] = value;
writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 });

const encKey = resolveSecretsEncryptionKey();
if (encKey) {
const encrypted = encryptJson(existing, encKey);
writeFileSync(filePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
} else {
// Fallback: write plaintext (same as before — for backwards compatibility
// when no encryption key is configured at all)
writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 });
}
}

/**
Expand All @@ -56,7 +137,14 @@ export function deletePluginSecret(pluginName: string, key: string): boolean {
if (!(key in existing)) return false;
delete existing[key];
const filePath = getSecretsPath(pluginName);
writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 });

const encKey = resolveSecretsEncryptionKey();
if (encKey) {
const encrypted = encryptJson(existing, encKey);
writeFileSync(filePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
} else {
writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 });
}
return true;
}

Expand Down
15 changes: 5 additions & 10 deletions src/ton/__tests__/wallet-encryption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,19 +196,14 @@ describe("saveWallet / loadWallet (encryption integration)", () => {
}
});

it("saves plaintext when no encryption key is set", () => {
it("throws when no encryption key is set (plaintext saving is rejected)", () => {
delete process.env.TELETON_WALLET_KEY;
mockFs.existsSync.mockReturnValue(true);

saveWallet(TEST_WALLET);

expect(mockFs.writeFileSync).toHaveBeenCalledOnce();
const written = mockFs.writeFileSync.mock.calls[0][1] as string;
const parsed = JSON.parse(written);
// Plaintext format: mnemonic array is visible
expect(parsed.encrypted).toBeUndefined();
expect(Array.isArray(parsed.mnemonic)).toBe(true);
expect(parsed.mnemonic).toEqual(TEST_MNEMONIC);
// SECURITY: Plaintext wallet saving is no longer allowed.
// An encryption key (TELETON_WALLET_KEY) is mandatory.
expect(() => saveWallet(TEST_WALLET)).toThrow(/TELETON_WALLET_KEY is required/);
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
});

it("saves encrypted format when key is set", () => {
Expand Down
Loading
Loading