Skip to content
Closed
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
15 changes: 0 additions & 15 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,18 +114,3 @@
**Vulnerability:** User-controlled input in file names and asset metadata was rendered without proper sanitization, allowing execution of arbitrary JavaScript (e.g. `<img src=x onerror=alert(1)>`).
**Learning:** React escapes text children by default, but relying on this is not enough if variables are passed to components that might render them unsafely, or if scanning tools mandate explicit sanitization functions for user-provided data.
**Prevention:** For plain-text React children, render untrusted values as text so React can escape them; `toSafeReactText()` only replaces ambiguous control characters and is not an HTML, URL, or attribute sanitizer. Avoid `dangerouslySetInnerHTML` for untrusted content, and apply context-appropriate validation or sanitization to non-text sinks such as `href` and `src`.

## 2026-07-15 - Prevent URL-Encoded Path Traversal in Email Import
**Vulnerability:** The `_safe_upload_filename` in `email_import_service.py` checked for path traversals (like `..`) without unquoting the filename first. This allowed an attacker to bypass the validation by URL-encoding or doubly URL-encoding the payload (e.g., `%2e%2e%2fupload` or `%252e%252e%252fupload`).
**Learning:** Checking for traversal sequences on raw filenames is insufficient if the input path can contain URL-encoded payloads. The check could be bypassed since it happens before decoding, yet the application or storage mechanism may later decode and use the dangerous payload.
**Prevention:** Always recursively decode `urllib.parse.unquote()` on raw input paths before validating, splitting, or extracting filenames to ensure doubly URL-encoded payloads are correctly decoded and caught, with a bounded loop to avoid DoS.

## 2026-07-22 - Prevent Path Traversal via Backslashes
**Vulnerability:** The `_safe_upload_filename` in `email_import_service.py` checked for path traversals (like `..`) but did not convert backslashes (`\`) to forward slashes (`/`), meaning that Windows-style path traversal attacks (like `..\..\upload` or encoded versions) could bypass the check.
**Learning:** Checking for traversal sequences using `pathlib.Path.name` may leave the result vulnerable if the input path can contain Windows-style path separators but the program interprets it dynamically or decodes payloads using backslashes.
**Prevention:** Always convert backslashes to forward slashes before parsing filenames.

## 2026-07-29 - Prevent SSRF via Local/Internal Domains
**Vulnerability:** The URL validation logic correctly blocked non-global IP addresses and `localhost`, but failed to block internal domain extensions such as `.internal` or `.local` (or exact matches for `internal`). This could allow attackers to bypass SSRF protections by resolving these internal top-level domains.
**Learning:** Checking for `localhost` alone is insufficient to prevent SSRF against internal network resources, as modern environments and protocols utilize `.internal` and `.local` domains for internal routing.
**Prevention:** Always explicitly check and block domains matching `.internal`, `.local`, or `internal` (alongside `localhost`) when validating URLs for global reachability to prevent SSRF bypasses.
13 changes: 0 additions & 13 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,4 @@
## [Unreleased]
### ๋ณด์•ˆ ํŒจ์น˜ (CodeQL extended current-head)

- CodeQL `extended` ๊ธฐ๋ณธ ์„ค์ •์ด current `develop`์—์„œ ํ™•์ธํ•œ Critical 8๊ฑดยทHigh 21๊ฑดยทMedium 1๊ฑด์„ ์ฝ”๋“œ ๊ฒฝ๊ณ„์—์„œ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. ์„œ๋ฒ„ ์š”์ฒญ์€ ๊ฒ€์ฆ๋œ loopback/HTTPS origin, ๋™์ผ OIDC issuer origin, ํ—ˆ์šฉ API ๊ฒฝ๋กœยท์ฟผ๋ฆฌ๋งŒ ์žฌ๊ตฌ์„ฑํ•˜๊ณ  redirect๋ฅผ ์ž๋™ ์ถ”์ข…ํ•˜์ง€ ์•Š์œผ๋ฉฐ, ๊ณต๊ฐœ IPv6 authority๋ฅผ ๋ณด์กดํ•ฉ๋‹ˆ๋‹ค. UI smoke๋Š” ๊ณ ์ • Node/Next ์‹คํ–‰ ํŒŒ์ผ๊ณผ ์ธ์ž, localhost:3001 allowlist, private `mkdtemp` artifact ๋””๋ ‰ํ„ฐ๋ฆฌ ๋ฐ containment ๊ฒ€์‚ฌ๋งŒ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
- OIDC token endpoint๋Š” ์šด์˜ ํ™˜๊ฒฝ์—์„œ ์„œ๋ฒ„ ์ „์šฉ `OIDC_ALLOWED_HOSTS` ์ •ํ™• ํ˜ธ์ŠคํŠธ allowlist๋ฅผ ํ•„์ˆ˜๋กœ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. hostname์˜ ๋ชจ๋“  DNS ๊ฒฐ๊ณผ๊ฐ€ ๊ณต์ธ ์ฃผ์†Œ์ธ์ง€ ๊ฒ€์ฆํ•œ ๋’ค ํ•ด๋‹น ์ฃผ์†Œ ์ง‘ํ•ฉ์„ native HTTP(S) ์—ฐ๊ฒฐ์˜ `lookup`์— ๊ณ ์ •ํ•˜๊ณ , ์›๋ž˜ issuer hostname์€ Host/TLS SNI๋กœ ์œ ์ง€ํ•ด ์‚ฌ์„ค ์ฃผ์†Œ ํ•ด์„๊ณผ DNS rebinding ์‚ฌ์ด์˜ TOCTOU๋ฅผ ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค. ์‹คํŒจ ๋กœ๊ทธ๋Š” ์ž…๋ ฅ URLยทtoken ๋Œ€์‹  ๊ณ ์ •๋œ configuration/DNSยทtransport/response/backend-verification reason code๋งŒ ๋‚จ๊น๋‹ˆ๋‹ค.
- Trivy 2026-07-26 DB์—์„œ ์ƒˆ๋กœ ํ™•์ธ๋œ Next.js High 4๊ฑดยทMedium 5๊ฑด(`CVE-2026-64641`โ€“`CVE-2026-64649`)๊ณผ PostCSS High 1๊ฑด(`GHSA-r28c-9q8g-f849`)์„ ์ œ๊ฑฐํ•˜๊ธฐ ์œ„ํ•ด Next.js/`eslint-config-next`๋ฅผ `16.2.11`, PostCSS๋ฅผ `8.5.18`๋กœ ๊ฐฑ์‹ ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‘ ๋ฒ„์ „์€ ๊ฐ ์ทจ์•ฝ์ ์˜ ์ตœ์ดˆ ์ˆ˜์ • ๋ฒ„์ „์ด๋ฉฐ ์ €์žฅ์†Œ์˜ release-age ์ •์ฑ…์„ ์šฐํšŒํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
- `pnpm audit`๊ฐ€ ๊ฐœ๋ฐœ ๋„๊ตฌ ์ฒด์ธ์—์„œ ์ถ”๊ฐ€ ํƒ์ง€ํ•œ `brace-expansion <=5.0.7` High DoS(`GHSA-mh99-v99m-4gvg`)๋Š” `5.0.8` ์ „์—ญ override๋กœ ์ œ๊ฑฐํ–ˆ์Šต๋‹ˆ๋‹ค. CommonJS default export๋ฅผ ๊ธฐ๋Œ€ํ•˜๋Š” legacy `minimatch 3.1.5`์—๋Š” `expand` named export๋„ ์ˆ˜์šฉํ•˜๋Š” ์ตœ์†Œ pnpm ํŒจ์น˜๋ฅผ ์ ์šฉํ•ด ESLint/glob ๋™์ž‘์„ ๋ณด์กดํ•ฉ๋‹ˆ๋‹ค.
- rootยทfrontend Docker build์˜ frozen install ๊ณ„์ธต์ด pnpm manifest์™€ ํ•จ๊ป˜ `frontend/patches`๋ฅผ ๋จผ์ € ๋ณต์‚ฌํ•˜๋„๋ก ์ˆ˜์ •ํ•ด, ์ด๋ฏธ์ง€ ๊ฒ€์ฆ์—์„œ๋„ lockfile์˜ patched dependency๋ฅผ ๋™์ผํ•˜๊ฒŒ ์žฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.
- Scorecard SARIF normalizer๋Š” ๊ณ ์ • workspace artifact๋กœ ์ •๊ทœํ™”๋˜๋Š” `./scorecard-results.sarif`์™€ ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋ฅผ ๋™์ผํ•˜๊ฒŒ ํ—ˆ์šฉํ•˜๋ฉด์„œ symlinkยทworkspace ์ดํƒˆ์€ ๊ณ„์† ๊ฑฐ๋ถ€ํ•ฉ๋‹ˆ๋‹ค. ๋„๊ตฌ ์‹คํ–‰ ์‹คํŒจ API๋Š” CR/LFยท์ œ์–ด ๋ฌธ์ž๋ฅผ escapeํ•˜๊ณ  500์ž๋กœ ์ œํ•œํ•˜๋ฉฐ, ๋กœ๊ทธ์—๋Š” raw ๋„๊ตฌ ์ฝ”๋“œยท์˜ˆ์™ธ text ๋Œ€์‹  SHA-256 ๊ธฐ๋ฐ˜ ์ฝ”๋“œยทtraceback ์ƒ๊ด€ ์‹๋ณ„์ž๋งŒ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค.
- ๋ฐฑ์—”๋“œ origin ๋ณด์•ˆ ๊ฒฝ๊ณ„๋ฅผ `frontend/src/lib/backend-url.ts`์˜ ๋‹จ์ผ ์ƒ์„ฑ๊ธฐ๋กœ ํ†ตํ•ฉํ•ด API proxyยทsessionยทOIDC callback์ด ๊ฐ™์€ ๊ฒ€์ฆ์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. UI smoke์˜ ์ƒˆ `NARUON_FULL_PRODUCT_SCREENSHOT_PROFILE` ์ด๋ฆ„์€ ์‹ค์ œ selector ์˜๋ฏธ๋ฅผ ๋“œ๋Ÿฌ๋‚ด๋ฉฐ, ๊ธฐ์กด `..._SCREENSHOT_DIR`์€ ํ˜ธํ™˜ alias๋กœ ๊ณ„์† ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.
- PR governance์˜ CodeRabbit issue-summary ๋ถ„๋ฅ˜๋Š” ์‹ค์ œ pre-merge ์‹คํŒจยทblocking findingยทactionable comment ์‹ ํ˜ธ๋งŒ ์ฐจ๋‹จํ•˜๊ณ , current-head SHA๊ฐ€ ํฌํ•จ๋œ `Review limit reached` ๊ฐ™์€ ์šด์˜ quota ์•ˆ๋‚ด๋Š” ์†Œ์Šค ๊ฒฐํ•จ์œผ๋กœ ์˜ค๋ถ„๋ฅ˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. check-run ๊ฒฐ๋ก ๊ณผ inline review comment ๊ฒ€์‚ฌ๋Š” ๊ทธ๋Œ€๋กœ ์œ ์ง€๋ฉ๋‹ˆ๋‹ค.
- ์ œํ’ˆ ์ด๋ฒคํŠธ ID์˜ `Math.random()` fallback์„ Web Crypto ๊ธฐ๋ฐ˜ UUID/128-bit ๋‚œ์ˆ˜๋กœ ๊ต์ฒดํ•˜๊ณ , ๊ฐœ์ธ ๋ฉ”์ผ smokeยทlive HTTPยทScorecard SARIF ๊ฒฝ๋กœ์— home/workspace containment, symlinkยทํฌ๊ธฐยทZIP entry ์ œํ•œ, loopback endpoint allowlist๋ฅผ ์ ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. ๋„๊ตฌ ์‹คํŒจ ๋กœ๊ทธ๋Š” ์‚ฌ์šฉ์ž ์ž…๋ ฅ ๋Œ€์‹  ๊ณ ์ • event์™€ ์˜ˆ์™ธ ์œ ํ˜•๋งŒ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค.
- ๊ฒ€์ฆ: ๋ฐฑ์—”๋“œ `1560 passed, 32 skipped`(`PYTHONWARNINGS=error`), ํ”„๋ŸฐํŠธ `385 passed`, ESLint, Ruff, TypeScript, Next.js production build, ๋ณ€๊ฒฝ Python ๋Œ€์ƒ Bandit Medium ์ด์ƒ ๊ฒ€์‚ฌ, Trivy Medium ์ด์ƒ ๊ฒ€์‚ฌ์™€ ์ •ํ™•ํ•œ hash/lock ์ž…๋ ฅ์˜ OSV ๊ฒ€์‚ฌ๊ฐ€ ํ†ต๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค. GitHub hosted CodeQL/SARIF current-head ๊ฒฐ๊ณผ๋Š” PR checks์—์„œ ๋ณ„๋„๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

### ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ •ํ•ฉ์„ฑ (Alembic single-head ๋ณต๊ตฌ)

- Alembic ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ทธ๋ž˜ํ”„์˜ head๊ฐ€ ๋‘˜๋กœ ๊ฐˆ๋ผ์ ธ(`0011_email_read_state` โ€” `email_records.is_read` ์ฝ์Œ-์ƒํƒœ ๋ธŒ๋žœ์น˜๊ฐ€ 0009์—์„œ ๋ถ„๊ธฐ, `0013_scopeweave_promotion` โ€” 0010โ†’0013 ๋ฉ”์ธ๋ผ์ธ) `scripts/migrate_db.py`์˜ ๊ด€๋ฆฌํ˜• ๊ฒฝ๋กœ `alembic upgrade head`(๋‹จ์ˆ˜)๊ฐ€ "Multiple head revisions are present"๋กœ ์‹คํŒจํ•˜๋˜ ๋ฌธ์ œ๋ฅผ ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ์Šคํ‚ค๋งˆ ๋ณ€๊ฒฝ์ด ์—†๋Š” no-op ๋จธ์ง€ ๋ฆฌ๋น„์ „ `0014_merge_email_read_state`(`down_revision = ("0011_email_read_state", "0013_scopeweave_promotion")`)๋กœ ๋‘ head๋ฅผ ๋‹จ์ผ head๋กœ ์žฌ๊ฒฐํ•ฉํ–ˆ์Šต๋‹ˆ๋‹ค(์–‘ ๋ธŒ๋žœ์น˜์˜ DDL์€ ๊ฐ์ž ์ด๋ฏธ ์ ์šฉ๋˜๋ฏ€๋กœ ๋จธ์ง€๋Š” ๊ทธ๋ž˜ํ”„๋งŒ ํ†ตํ•ฉ). ์žฌ๋ฐœ ๋ฐฉ์ง€ ๊ฐ€๋“œ๋กœ `tests/test_alembic_migrations.py`์— ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ทธ๋ž˜ํ”„ head๊ฐ€ ์ •ํ™•ํžˆ 1๊ฐœ์ž„์„ ๊ฒ€์ฆํ•˜๋Š” ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ(`test_alembic_migration_graph_has_a_single_head`)๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค โ€” ๊ธฐ์กด ๊ฐ€๋“œ๋Š” revision id ๊ธธ์ด๋งŒ ๊ฒ€์‚ฌํ•ด ๋‹ค์ค‘ head๋ฅผ ๋†“์ณค์Šต๋‹ˆ๋‹ค. ๊ฒ€์ฆ: ์ „์ฒด ๋ฐฑ์—”๋“œ ์Šค์œ„ํŠธ 1346 passedยท0 failed(`PYTHONWARNINGS=error`, forbidden-word 0), ruff clean, alembic `ScriptDirectory.get_heads()` == 1.
Expand Down
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ RUN mkdir -p /opt/pnpm \
&& ln -sf /opt/pnpm/bin/pnpx.cjs /usr/local/bin/pnpx \
&& rm /tmp/pnpm.tgz
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml frontend/.pnpmfile.cjs ./
COPY frontend/patches ./patches
RUN pnpm install --frozen-lockfile
COPY frontend ./
ENV NEXT_TELEMETRY_DISABLED=1
Expand Down
11 changes: 7 additions & 4 deletions backend/api/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
MAX_IMPORT_UPLOADS,
EmailImportItemStatus,
EmailImportUpload,
canonical_email_import_upload_filename,
import_email_uploads,
)
from services.llm_provider_selection import resolve_runtime_llm_provider
Expand Down Expand Up @@ -577,16 +576,20 @@ async def import_email_files(

uploads: list[EmailImportUpload] = []
for upload in files:
canonical_filename = canonical_email_import_upload_filename(upload.filename)
if canonical_filename is None:
normalized_filename = upload.filename.lower().strip() if upload.filename else ""
if not upload.filename or not (
normalized_filename.endswith(".eml")
or normalized_filename.endswith(".zip")
or normalized_filename.endswith(".mbox")
):
raise HTTPException(status_code=400, detail="invalid_file_type")

content = await upload.read(MAX_IMPORT_UPLOAD_BYTES + 1)
if len(content) > MAX_IMPORT_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="file_too_large")
uploads.append(
EmailImportUpload(
filename=canonical_filename,
filename=upload.filename or "upload",
content=content,
)
)
Expand Down
230 changes: 4 additions & 226 deletions backend/api/tools.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import base64
import hashlib
import inspect
import json
import logging
import re
import unicodedata
import urllib.parse
from collections import Counter
from collections.abc import Callable
from typing import Any, Dict, List, Optional

Expand All @@ -24,52 +20,6 @@
router = APIRouter(prefix="/api", tags=["tools"])
logger = logging.getLogger(__name__)
ToolHandler = Callable[[Dict[str, Any]], Any]
MAX_TOOL_FAILURE_MESSAGE_CHARS = 500


def _tool_code_fingerprint(code: str) -> str:
"""Return a stable non-reversible identifier for correlating tool failures."""
return hashlib.sha256(code.encode("utf-8", errors="replace")).hexdigest()[:12]


def _exception_traceback_fingerprint(exc: Exception) -> str:
"""Fingerprint traceback locations without logging exception text or source."""
locations: list[str] = []
traceback_cursor = exc.__traceback__
while traceback_cursor is not None:
frame = traceback_cursor.tb_frame
locations.append(
f"{frame.f_code.co_filename}:{frame.f_code.co_name}:"
f"{traceback_cursor.tb_lineno}"
)
traceback_cursor = traceback_cursor.tb_next
material = "\n".join(locations) or type(exc).__name__
return hashlib.sha256(material.encode("utf-8", errors="replace")).hexdigest()[:12]


def _safe_tool_failure_message(exc: Exception) -> str:
"""Return a bounded single-line exception message for the API response."""
raw = str(exc) or type(exc).__name__
escaped: list[str] = []
escaped_length = 0
for character in raw:
codepoint = ord(character)
if character == "\r":
fragment = "\\r"
elif character == "\n":
fragment = "\\n"
elif character == "\t":
fragment = "\\t"
elif codepoint < 0x20 or codepoint == 0x7F or codepoint in {0x2028, 0x2029}:
fragment = f"\\u{codepoint:04x}"
else:
fragment = character
escaped.append(fragment)
escaped_length += len(fragment)
if escaped_length >= MAX_TOOL_FAILURE_MESSAGE_CHARS:
break
message = "".join(escaped)[:MAX_TOOL_FAILURE_MESSAGE_CHARS]
return message or "Tool execution failed"


class ToolInfo(BaseModel):
Expand Down Expand Up @@ -245,6 +195,8 @@ async def tone_analyzer_handler(params: Dict[str, Any]) -> Any:
"tone_score": 85,
}



def _detect_text_language(text: str) -> str:
if any("\uac00" <= char <= "\ud7a3" for char in text):
return "ko"
Expand Down Expand Up @@ -654,173 +606,6 @@ async def base64_decoder_handler(params: Dict[str, Any]) -> Dict[str, str]:
)


ANALYSIS_TEXT_MAX_CHARS = 100_000
_ANALYSIS_TOKEN_PATTERN = re.compile(r"[^\W_]+(?:['โ€™][^\W_]+)?", re.UNICODE)
_KEYWORD_STOPWORDS = frozenset(
{
"about",
"after",
"again",
"because",
"before",
"could",
"from",
"have",
"into",
"should",
"their",
"there",
"these",
"they",
"this",
"those",
"through",
"very",
"with",
"would",
"๊ทธ๋ฆฌ๊ณ ",
"๊ทธ๋Ÿฌ๋‚˜",
"๋Œ€ํ•œ",
"์œ„ํ•œ",
"์žˆ๋Š”",
"ํ•ฉ๋‹ˆ๋‹ค",
}
)
_CATEGORY_TERMS = (
("Urgent", ("urgent", "asap", "immediate", "๊ธด๊ธ‰", "์‹œ๊ธ‰", "๋นจ๋ฆฌ")),
("Finance", ("invoice", "billing", "payment", "๊ฒฐ์ œ", "์ฒญ๊ตฌ", "์†ก๊ธˆ")),
("Scheduling", ("meeting", "schedule", "appointment", "ํšŒ์˜", "์ผ์ •", "์•ฝ์†")),
)
_AGENDA_TOPICS = (
("Project Status Update", ("project", "ํ”„๋กœ์ ํŠธ", "๊ณผ์ œ")),
("Discuss Pending Issues", ("issue", "bug", "blocker", "๋ฌธ์ œ", "์˜ค๋ฅ˜", "์žฅ์• ")),
("Decisions Required", ("decision", "approve", "๊ฒฐ์ •", "์Šน์ธ")),
(
"Timeline and Milestones",
("deadline", "milestone", "timeline", "๋งˆ๊ฐ", "๊ธฐํ•œ", "์ผ์ •"),
),
(
"Budget and Resource Review",
("budget", "cost", "resource", "์˜ˆ์‚ฐ", "๋น„์šฉ", "์ž์›"),
),
)


def _normalize_analysis_text(value: str) -> str:
"""Normalize user text for deterministic, multilingual rule matching."""
if len(value) > ANALYSIS_TEXT_MAX_CHARS:
raise ValueError(
f"Analysis text must not exceed {ANALYSIS_TEXT_MAX_CHARS} characters"
)
return unicodedata.normalize("NFKC", value).casefold()


def _analysis_tokens(value: str) -> list[str]:
"""Return normalized Unicode word tokens without punctuation or underscores."""
return _ANALYSIS_TOKEN_PATTERN.findall(_normalize_analysis_text(value))


def _contains_analysis_term(normalized_text: str, term: str) -> bool:
"""Match ASCII terms on word boundaries and Korean terms as morpheme stems."""
normalized_term = _normalize_analysis_text(term)
if normalized_term.isascii():
pattern = rf"(?<![a-z0-9]){re.escape(normalized_term)}(?![a-z0-9])"
return re.search(pattern, normalized_text) is not None
return normalized_term in normalized_text


async def email_categorizer_handler(params: Dict[str, Any]) -> Any:
"""Categorize email text with deterministic Korean and English rules."""
content = _normalize_analysis_text(params.get("email_content", ""))
categories = [
category
for category, terms in _CATEGORY_TERMS
if any(_contains_analysis_term(content, term) for term in terms)
]

if not categories:
categories = ["General"]

return {"categories": categories, "primary_category": categories[0]}


registry.register(
ToolInfo(
code="email_categorizer",
name="์ด๋ฉ”์ผ ์ž๋™ ๋ถ„๋ฅ˜๊ธฐ (Email Categorizer)",
description="์ด๋ฉ”์ผ ๋‚ด์šฉ์„ ๋ถ„์„ํ•˜์—ฌ ์•Œ๋งž์€ ์นดํ…Œ๊ณ ๋ฆฌ๋กœ ์ž๋™ ๋ถ„๋ฅ˜ํ•ฉ๋‹ˆ๋‹ค.",
category="์ด๋ฉ”์ผ ๋ถ„์„",
parameters={"email_content": "string"},
),
email_categorizer_handler,
)


async def keyword_extractor_handler(params: Dict[str, Any]) -> Any:
"""Extract stable keywords ranked by frequency and first occurrence."""
candidates = [
token
for token in _analysis_tokens(params.get("text", ""))
if token not in _KEYWORD_STOPWORDS
and not token.isdecimal()
and (len(token) >= 4 if token.isascii() else len(token) >= 2)
]
frequencies = Counter(candidates)
first_positions: dict[str, int] = {}
for index, token in enumerate(candidates):
first_positions.setdefault(token, index)
keywords = sorted(
frequencies,
key=lambda token: (-frequencies[token], first_positions[token], token),
)[:5]

return {"keywords": keywords, "keyword_count": len(keywords)}


registry.register(
ToolInfo(
code="keyword_extractor",
name="์ฃผ์š” ํ‚ค์›Œ๋“œ ์ถ”์ถœ๊ธฐ (Keyword Extractor)",
description="ํ…์ŠคํŠธ ๋ณธ๋ฌธ์—์„œ ๊ฐ€์žฅ ์ค‘์š”ํ•œ ํ‚ค์›Œ๋“œ๋ฅผ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค.",
category="์ด๋ฉ”์ผ ๋ถ„์„",
parameters={"text": "string"},
),
keyword_extractor_handler,
)


async def meeting_agenda_generator_handler(params: Dict[str, Any]) -> Any:
"""Generate a deterministic agenda from Korean or English discussion topics."""
context = _normalize_analysis_text(params.get("discussion_context", ""))
if len(_analysis_tokens(context)) < 2:
return {
"agenda_items": ["Introductions", "Open Discussion"],
"estimated_duration_minutes": 30,
}

items = ["Review previous action items"]
items.extend(
agenda_item
for agenda_item, terms in _AGENDA_TOPICS
if any(_contains_analysis_term(context, term) for term in terms)
)
items.append("Next Steps and Action Items")

return {"agenda_items": items, "estimated_duration_minutes": len(items) * 15}


registry.register(
ToolInfo(
code="meeting_agenda_generator",
name="ํšŒ์˜ ์•„์  ๋‹ค ์ƒ์„ฑ๊ธฐ (Meeting Agenda Generator)",
description="๋…ผ์˜ ์ปจํ…์ŠคํŠธ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์ ์ ˆํ•œ ํšŒ์˜ ์•„์  ๋‹ค๋ฅผ ์ž๋™์œผ๋กœ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.",
category="์ผ์ • ๊ด€๋ฆฌ",
parameters={"discussion_context": "string"},
),
meeting_agenda_generator_handler,
)


@router.get("/tools", response_model=list[ToolInfo])
def get_tools() -> list[ToolInfo]:
"""
Expand Down Expand Up @@ -928,16 +713,9 @@ async def execute_tool(code: str, request: ExecuteRequest) -> ExecuteResponse:
status="success", result=result, message="Execution successful"
)
except Exception as e:
logger.warning(
"tool_execution_failed",
extra={
"exception_type": type(e).__name__,
"exception_traceback_fingerprint": _exception_traceback_fingerprint(e),
"tool_code_fingerprint": _tool_code_fingerprint(code),
},
)
logger.exception("Tool execution failed", extra={"tool_code": code})
return ExecuteResponse(
status="failed",
result=None,
message=_safe_tool_failure_message(e),
message=str(e),
)
Loading
Loading