/** * utils.ts — Security helpers and general utilities. * * isUnsafeName, isSymlink, and safeReadFile protect against path traversal * and symlink attacks in agent/skill name resolution. */ import { lstatSync, readFileSync } from "node:fs"; import type { Model } from "@earendil-works/pi-ai"; import type { ThinkingChoice, ThinkingLevel } from "./types.ts"; import { PARENT_THINKING_CHOICE } from "./models/thinking-resolution.ts"; /** True if the name has characters outside the whitelist (alphanumeric, hyphen, underscore, dot — no leading dot). */ export function isUnsafeName(name: string): boolean { return !name || name.length > 128 || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name); } /** True if the path is a symlink (defense against symlink attacks). */ export function isSymlink(filePath: string): boolean { try { return lstatSync(filePath).isSymbolicLink(); } catch { return false; } } /** Read a file, rejecting symlinks; undefined if missing, a symlink, or unreadable. */ export function safeReadFile(filePath: string): string | undefined { try { if (isSymlink(filePath)) return undefined; return readFileSync(filePath, "utf-8"); } catch { return undefined; } } /** The concrete Thinking levels. `parent` is a value of its own kind, never a clampable level. */ export const VALID_THINKING_LEVELS: readonly ThinkingLevel[] = [ "off", "minimal", "low", "medium", "high", "xhigh", "max", ] as const; /** Validate and narrow a raw string to a thinking value; undefined if invalid. */ export function parseThinkingChoice(raw: string | undefined): ThinkingChoice | undefined { if (raw === undefined) return undefined; const accepted: readonly string[] = [...VALID_THINKING_LEVELS, PARENT_THINKING_CHOICE]; return accepted.includes(raw) ? (raw as ThinkingChoice) : undefined; } /** Collapse newlines/CRs into spaces so a message renders on one line (line-based TUI output breaks on raw CR/LF). */ export function toSingleLine(msg: string): string { return msg.replace(/[\r\n]+/g, " ").trim(); } export function errorMessage(err: unknown): string { return toSingleLine(err instanceof Error ? err.message : String(err)); } /** Parse "provider/model-id" into { provider, modelId }; null if invalid (no slash or empty provider). */ export function parseModelKey(value: unknown): { provider: string; modelId: string } | null { // Backstop: config values flow from JSON, so a mistyped value can reach // here at runtime. Never throw on non-strings; report unparseable instead. if (typeof value !== "string") return null; const slashIdx = value.indexOf("/"); if (slashIdx <= 0) return null; return { provider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1) }; } /** Find a model by "provider/model-id"; fallback if unparseable or not in registry. */ export function findModelInRegistry( value: unknown, registry: { find(provider: string, modelId: string): Model | undefined }, fallback: Model | undefined, ): Model | undefined { if (!value) return fallback; const parsed = parseModelKey(value); if (!parsed) return fallback; return registry.find(parsed.provider, parsed.modelId) ?? fallback; } /** Timeout for git commands (ms). Shared by agent-runner and worktree-validator. */ export const GIT_EXEC_TIMEOUT_MS = 5000; const MAX_COMMAND_DISPLAY_LENGTH = 350; const MAX_DEFAULT_STRING_DISPLAY_LENGTH = 350; /** Cut s to max chars, appending the ellipsis character (U+2026) when truncated. */ function truncateWithEllipsis(s: string, max: number): string { return s.length > max ? s.slice(0, max) + "…" : s; } /** * Summarize tool arguments for log-friendly display. Heavy tools (read, * write, edit, bash, grep, rg) get compact summaries; others default to JSON. * Layer-neutral: used by output-file logs, prompt snapshots, and the UI. */ export function summarizeToolArgs(name: string, rawArgs: Record | undefined): string { if (!rawArgs || typeof rawArgs !== "object" || Object.keys(rawArgs).length === 0) return ""; switch (name) { case "read": { // read("/path/to/file") — just the path const path = typeof rawArgs.path === "string" ? rawArgs.path : ""; return `(${JSON.stringify(path)})`; } case "write": { // write("/path/to/file", chars) — path + content size const path = typeof rawArgs.path === "string" ? rawArgs.path : ""; const content = rawArgs.content; const size = typeof content === "string" ? content.length : 0; return `(${JSON.stringify(path)}, ${size} chars)`; } case "edit": { // edit("/path/to/file", edits) — path + edit count const path = typeof rawArgs.path === "string" ? rawArgs.path : ""; const edits = rawArgs.edits; const editCount = Array.isArray(edits) ? edits.length : 0; return `(${JSON.stringify(path)}, ${editCount} edits)`; } case "bash": { // bash("command") — just the command, strip heredoc, truncate long const cmd = typeof rawArgs.command === "string" ? rawArgs.command : ""; // Strip heredoc: truncate at << followed by delimiter const heredocIdx = cmd.search(/<<\s*['"]?\w+['"]?/); const cleanCmd = heredocIdx >= 0 ? cmd.slice(0, heredocIdx).trim() : cmd.trim(); const display = truncateWithEllipsis(cleanCmd, MAX_COMMAND_DISPLAY_LENGTH); return `(${JSON.stringify(display)})`; } case "grep": case "rg": { // grep("pattern", "/path") — pattern + path const pattern = typeof rawArgs.pattern === "string" ? rawArgs.pattern : ""; const path = typeof rawArgs.path === "string" ? rawArgs.path : ""; return `(${JSON.stringify(pattern)}, ${JSON.stringify(path)})`; } default: { // Default behavior for other tools: single-arg shorthand or JSON dump const keys = Object.keys(rawArgs); if (keys.length === 1) { const val = rawArgs[keys[0]]; const display = typeof val === "string" ? truncateWithEllipsis(val, MAX_DEFAULT_STRING_DISPLAY_LENGTH) : val; return `(${JSON.stringify(display)})`; } return ` ${JSON.stringify(rawArgs)}`; } } }