From 4462a94c9b0df946b96dc628cc607bc682cf16fb Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:57:47 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=B2=84=ED=8A=BC=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EC=9E=91=EC=97=85=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/palette.md | 3 +++ frontend/src/components/ProjectsLayout.tsx | 1 + frontend/src/components/data-layout/DocumentRepositoryTab.tsx | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/.jules/palette.md b/.jules/palette.md index bdb0a4bbd..fc89e8a51 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -80,3 +80,6 @@ ## 2025-05-19 - Dynamic ARIA labels and robust disabled states for sidebar actions **Learning:** Hardcoded ARIA labels in mockups (like "출시 회의 일정 삭제") are often left intact during implementation, leading to incorrect screen reader announcements when different items are selected. In addition, action buttons that depend on selection state often lack correct visual and functional disabled states. **Action:** When implementing detail views or sidebars, always replace hardcoded mockup ARIA labels with dynamic data (e.g. `${event.title} 삭제`), and ensure action buttons are explicitly disabled (both functionally via `disabled` and visually via `opacity-50 cursor-not-allowed`) when their prerequisites (like a selected item or specific properties like location) are unmet. +## 2025-02-14 - Loading State Accessibility Improvement +**Learning:** Found multiple instances where buttons are disabled during asynchronous operations (e.g. `isDocumentActionLoading`, `correctionSubmitting`) without explicitly setting `aria-busy` to inform screen reader users that a background task is running. Setting `aria-busy` along with `disabled` provides essential context to AT users, differentiating an active process from a statically unavailable action. However, `aria-busy={false}` should not be applied to statically disabled elements. +**Action:** Always include `aria-busy={loadingStateVariable}` when disabling buttons during async operations. diff --git a/frontend/src/components/ProjectsLayout.tsx b/frontend/src/components/ProjectsLayout.tsx index 2f750ffd9..2d6506c3d 100644 --- a/frontend/src/components/ProjectsLayout.tsx +++ b/frontend/src/components/ProjectsLayout.tsx @@ -969,6 +969,7 @@ export function ProjectsLayout() { type="button" onClick={handleMarkEvidenceReviewed} disabled={correctionSubmitting || evidenceLoading} + aria-busy={correctionSubmitting || evidenceLoading} className="mt-3 min-h-9 w-full rounded-md bg-primary px-3 text-xs font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground" > {correctionSubmitting ? '검토 저장 중' : '문단 근거 검토 저장'} diff --git a/frontend/src/components/data-layout/DocumentRepositoryTab.tsx b/frontend/src/components/data-layout/DocumentRepositoryTab.tsx index 78a257061..aa6ef0756 100644 --- a/frontend/src/components/data-layout/DocumentRepositoryTab.tsx +++ b/frontend/src/components/data-layout/DocumentRepositoryTab.tsx @@ -375,6 +375,7 @@ return ( type="button" onClick={() => void requestDocumentAction('reparse')} disabled={isDocumentActionLoading} + aria-busy={isDocumentActionLoading} className="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary disabled:cursor-wait disabled:opacity-60" > @@ -384,6 +385,7 @@ return ( type="button" onClick={() => void requestDocumentAction('embedding-regeneration-intent')} disabled={isDocumentActionLoading} + aria-busy={isDocumentActionLoading} className="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary disabled:cursor-wait disabled:opacity-60" > @@ -393,6 +395,7 @@ return ( type="button" onClick={() => void requestDocumentAction('hwp-conversion-intent')} disabled={isDocumentActionLoading} + aria-busy={isDocumentActionLoading} className="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary disabled:cursor-wait disabled:opacity-60" > @@ -518,6 +521,7 @@ return ( type="button" onClick={() => void requestUniqueThreadIntent()} disabled={isUniqueThreadLoading} + aria-busy={isUniqueThreadLoading} className="w-full whitespace-nowrap rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-wait disabled:opacity-60 sm:w-auto" > 중복 메일 스레드 의도 점검 From b4bc21ca4b5115a98e30069163db3069a5d449f3 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:36:12 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=B2=84=ED=8A=BC=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EC=9E=91=EC=97=85=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-publish.yml | 60 ++----- .jules/bolt.md | 7 - .jules/palette.md | 3 + .jules/sentinel.md | 4 - CHANGELOG.md | 2 - Dockerfile | 13 +- Dockerfile.ollama | 2 +- backend/api/emails.py | 10 +- backend/api/tools.py | 44 +---- .../disksage_copy_readiness_handoff.py | 6 +- backend/services/email_client.py | 4 - backend/services/text_safety.py | 4 - backend/tests/runner/utils/test_dispatch.py | 15 -- .../test_container_dependency_pin_contract.py | 146 ---------------- .../test_disksage_copy_readiness_handoff.py | 10 -- backend/tests/test_email_client.py | 15 -- backend/tests/test_emails_api.py | 32 +--- backend/tests/test_oidc_jwks_preload.py | 38 ----- backend/tests/test_release_governance.py | 156 +++++++++++------- backend/tests/test_repo_hygiene.py | 2 +- backend/tests/test_text_safety.py | 4 - backend/tests/test_tools_api.py | 31 +--- connector/Dockerfile | 2 +- .../container-provenance-contract.md | 41 ----- .../email-authentication-xoauth2/README.md | 54 ------ frontend/Dockerfile | 11 +- frontend/dev.log | 61 +++++++ frontend/src/components/EmailDetail.test.tsx | 16 -- frontend/src/components/EmailDetail.tsx | 17 +- frontend/src/components/NetworkGraph.tsx | 32 +--- frontend/src/components/TasksLayout.tsx | 36 ++-- test_parse.py | 24 --- test_parse2.py | 14 -- test_parse3.py | 33 ---- 34 files changed, 231 insertions(+), 718 deletions(-) delete mode 100644 backend/tests/runner/utils/test_dispatch.py delete mode 100644 backend/tests/test_container_dependency_pin_contract.py delete mode 100644 backend/tests/test_oidc_jwks_preload.py delete mode 100644 docs/operations/container-provenance-contract.md delete mode 100644 docs/research/email-authentication-xoauth2/README.md create mode 100644 frontend/dev.log delete mode 100644 test_parse.py delete mode 100644 test_parse2.py delete mode 100644 test_parse3.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index fc7058413..879b906ec 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -32,21 +32,18 @@ jobs: - component: backend image: ai_email_client-backend dockerfile: Dockerfile - base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: naruon image: naruon dockerfile: Dockerfile - base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: frontend image: ai_email_client-frontend dockerfile: frontend/Dockerfile - base_dockerfile: frontend/Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 @@ -67,28 +64,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Resolve pinned Ollama base manifest - if: matrix.component == 'naruon' - run: | - base_image="$(awk 'toupper($1) == "FROM" { print $2; exit }' Dockerfile.ollama)" - if ! printf '%s\n' "$base_image" | grep -Eq '^ollama/ollama@sha256:[0-9a-f]{64}$'; then - printf '::error file=Dockerfile.ollama,line=1::Expected an exact ollama/ollama sha256 base pin; found %s\n' "$base_image" - exit 1 - fi - printf 'Resolving pinned Ollama base manifest: %s\n' "$base_image" - manifest_output="$(docker buildx imagetools inspect "$base_image")" - printf '%s\n' "$manifest_output" - for platform in linux/amd64 linux/arm64; do - if ! printf '%s\n' "$manifest_output" | grep -Eq "^[[:space:]]*Platform:[[:space:]]+${platform}[[:space:]]*$"; then - printf '::error file=Dockerfile.ollama,line=1::Pinned Ollama manifest is missing %s\n' "$platform" - exit 1 - fi - done - - name: Prepare OCI annotation values id: oci env: - BASE_DOCKERFILE: ${{ matrix.base_dockerfile }} GIT_REF_NAME: ${{ github.ref_name }} IMAGE_COMPONENT: ${{ matrix.component }} IMAGE_NAME: ${{ matrix.image }} @@ -98,29 +76,24 @@ jobs: version="$(cat VERSION)" created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" vendor="${REPOSITORY%%/*}" - base_reference="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$BASE_DOCKERFILE")" - if ! printf '%s\n' "$base_reference" | grep -Eq '^[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$'; then - printf '::error file=%s,line=1::Expected an exact tagged sha256 base pin; found %s\n' "$BASE_DOCKERFILE" "$base_reference" - exit 1 - fi - base_digest="${base_reference##*@}" - base_repository="${base_reference%@*}" - case "$base_repository" in - */*) base_name="$base_reference" ;; - *) base_name="docker.io/library/$base_reference" ;; - esac case "$IMAGE_COMPONENT" in frontend) title="naruon frontend" description="Naruon Next.js frontend runtime image" + base_digest="sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" + base_name="docker.io/library/node:26-slim@${base_digest}" ;; backend) title="naruon backend" description="Naruon FastAPI backend runtime image" + base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" + base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; *) title="naruon" description="Naruon combined FastAPI and Next.js runtime image" + base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" + base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; esac { @@ -185,21 +158,18 @@ jobs: - component: backend image: ai_email_client-backend dockerfile: Dockerfile - base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: naruon image: naruon dockerfile: Dockerfile - base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: frontend image: ai_email_client-frontend dockerfile: frontend/Dockerfile - base_dockerfile: frontend/Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 @@ -234,7 +204,6 @@ jobs: - name: Prepare OCI annotation values id: oci env: - BASE_DOCKERFILE: ${{ matrix.base_dockerfile }} GIT_REF_NAME: ${{ github.ref_name }} IMAGE_COMPONENT: ${{ matrix.component }} IMAGE_NAME: ${{ matrix.image }} @@ -245,29 +214,24 @@ jobs: version="${VERSION_VALUE:-$(cat VERSION)}" created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" vendor="${REPOSITORY%%/*}" - base_reference="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$BASE_DOCKERFILE")" - if ! printf '%s\n' "$base_reference" | grep -Eq '^[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$'; then - printf '::error file=%s,line=1::Expected an exact tagged sha256 base pin; found %s\n' "$BASE_DOCKERFILE" "$base_reference" - exit 1 - fi - base_digest="${base_reference##*@}" - base_repository="${base_reference%@*}" - case "$base_repository" in - */*) base_name="$base_reference" ;; - *) base_name="docker.io/library/$base_reference" ;; - esac case "$IMAGE_COMPONENT" in frontend) title="naruon frontend" description="Naruon Next.js frontend runtime image" + base_digest="sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" + base_name="docker.io/library/node:26-slim@${base_digest}" ;; backend) title="naruon backend" description="Naruon FastAPI backend runtime image" + base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" + base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; *) title="naruon" description="Naruon combined FastAPI and Next.js runtime image" + base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" + base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; esac { diff --git a/.jules/bolt.md b/.jules/bolt.md index fa2deda3f..d0b0a9997 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -19,10 +19,3 @@ **Learning:** When using a dictionary purely to track the presence of keys (e.g. `has_sent_message[key] = True`), checking for presence with `.get(key, False)` carries unnecessary semantic and memory overhead. Sets in Python provide a cleaner `key in set_name` syntax for boolean presence checks and slightly reduced memory footprint, while maintaining O(1) time complexity. **Action:** When tracking unique occurrences or boolean presence of items where the value itself doesn't carry additional information, use a `set` and its `.add()` and `in` operators instead of a `dict` mapping to `True` or `False`. -## 2025-02-12 - Replaced O(N) Array Lookups with O(1) Maps in Loops - -**Learning:** When generating derived UI state in `useMemo` that joins separate data arrays (like graph edges referencing node IDs), calling helper functions that use `Array.prototype.find()` for every item creates an `O(M * N)` bottleneck. -**Action:** When a loop needs to repeatedly look up related items from another array by ID, pre-compute an `O(N)` `Map` before the loop and use `map.get()` for `O(1)` lookups instead of inline array `.find()` calls. -## 2024-05-24 - [React Component Memoization] -**Learning:** In React components like `WorkspaceHome`, when layout state or polling changes trigger parent re-renders, expensive child components like `EmailDetail` will also re-render unnecessarily if not memoized. -**Action:** Always consider `React.memo` for heavy child components that rely on stable props (like IDs) when the parent component has frequent unrelated state updates. diff --git a/.jules/palette.md b/.jules/palette.md index bdb0a4bbd..fc89e8a51 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -80,3 +80,6 @@ ## 2025-05-19 - Dynamic ARIA labels and robust disabled states for sidebar actions **Learning:** Hardcoded ARIA labels in mockups (like "출시 회의 일정 삭제") are often left intact during implementation, leading to incorrect screen reader announcements when different items are selected. In addition, action buttons that depend on selection state often lack correct visual and functional disabled states. **Action:** When implementing detail views or sidebars, always replace hardcoded mockup ARIA labels with dynamic data (e.g. `${event.title} 삭제`), and ensure action buttons are explicitly disabled (both functionally via `disabled` and visually via `opacity-50 cursor-not-allowed`) when their prerequisites (like a selected item or specific properties like location) are unmet. +## 2025-02-14 - Loading State Accessibility Improvement +**Learning:** Found multiple instances where buttons are disabled during asynchronous operations (e.g. `isDocumentActionLoading`, `correctionSubmitting`) without explicitly setting `aria-busy` to inform screen reader users that a background task is running. Setting `aria-busy` along with `disabled` provides essential context to AT users, differentiating an active process from a statically unavailable action. However, `aria-busy={false}` should not be applied to statically disabled elements. +**Action:** Always include `aria-busy={loadingStateVariable}` when disabling buttons during async operations. diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 6f502e1c7..3f3dd68ba 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -129,7 +129,3 @@ **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. -## 2025-02-23 - CRLF Injection in Email Headers -**Vulnerability:** The `in_reply_to` and `references` fields on the `SendEmailRequest` model lacked explicit validation, opening up an opportunity for header injection by appending `\r\n`. -**Learning:** While the email service internally checks some headers, relying on the API boundary's Pydantic model ensures bad input is stopped early and consistently. Pydantic regex patterns aren't sufficient on their own for all string contexts due to encoding/decoding inconsistencies. -**Prevention:** Always use `@field_validator` with explicit `mode="before"` string matching to reject `chr(10)` and `chr(13)` across all user-controlled email header fields. Use `isinstance(value, str)` before string operations to prevent runtime errors if input is missing or malformed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dedb0b53..c2e15635d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,4 @@ ## [Unreleased] -- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다. -- UUID V4 제너레이터(`uuid_v4_generator`) 도구를 추가하여 런타임에서 범용 고유 식별자 버전 4를 랜덤으로 생성할 수 있게 하였습니다. 테스트 커버리지 100%를 보장합니다. ### 보안 패치 (CodeQL extended current-head) - `cryptography`를 `50.0.0`으로 갱신해 공격자 제공 PKCS#7 EnvelopedData 복호화 결과의 오류·타이밍 차이로 발생하는 Bleichenbacher oracle(`CVE-2026-69247`, `GHSA-g6cj-pr64-35w5`)을 제거하고, backend·uv lock·hash lock·Strix CI 의존성 증거를 같은 버전으로 동기화했습니다. Strix 잠금은 `google-cloud-aiplatform==1.160.0`의 `<7` 제약을 위반하던 `protobuf==7.35.1`을 이미 검증된 `6.33.6`으로 복구해 다시 해석·설치 가능하게 했습니다. diff --git a/Dockerfile b/Dockerfile index 68c5d2e91..d51e6dafc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Backend runtime for local Compose and backend-only deployments -FROM python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc AS backend-runtime +FROM python:3.14-slim@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1 AS backend-runtime WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 @@ -25,7 +25,7 @@ EXPOSE 8000 CMD ["python", "scripts/start_backend.py", "--host", "0.0.0.0", "--port", "8000"] # Stage 2: Build Frontend -FROM node:26-slim@sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503 AS frontend-builder +FROM node:26-slim@sha256:ffc78385a788964bb3cbab5e434ff79a10bdc25b8ae6db03fe5fe6cb14053c09 AS frontend-builder WORKDIR /app ENV NPM_CONFIG_UPDATE_NOTIFIER=false ENV PNPM_VERSION=11.5.3 @@ -63,13 +63,8 @@ ARG OCI_IMAGE_LICENSES="LicenseRef-Naruon-Proprietary" ARG OCI_IMAGE_REF_NAME="" ARG OCI_IMAGE_TITLE="naruon" ARG OCI_IMAGE_DESCRIPTION="Naruon combined FastAPI and Next.js runtime image" -ARG OCI_IMAGE_BASE_DIGEST="sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc" -ARG OCI_IMAGE_BASE_NAME="docker.io/library/python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc" - -# Defaults keep local builds provenance-complete. The publishing workflow derives -# and overrides both values from the exact first FROM instruction, while -# repository governance tests prevent the reviewed defaults from drifting. -RUN test -n "$OCI_IMAGE_BASE_DIGEST" && test -n "$OCI_IMAGE_BASE_NAME" +ARG OCI_IMAGE_BASE_DIGEST="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" +ARG OCI_IMAGE_BASE_NAME="docker.io/library/python:3.14-slim@sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" LABEL org.opencontainers.image.created="${OCI_IMAGE_CREATED}" \ org.opencontainers.image.authors="${OCI_IMAGE_AUTHORS}" \ diff --git a/Dockerfile.ollama b/Dockerfile.ollama index c4afd9598..d4b369689 100644 --- a/Dockerfile.ollama +++ b/Dockerfile.ollama @@ -1,4 +1,4 @@ -FROM ollama/ollama@sha256:b88c73ace3e115f8ec53dc8761ae1c0aabfa675406e3681786b98757ce050f42 +FROM ollama/ollama@sha256:509fdf54e23bd50d87af646cb51c0a7a203d6a83cc4d6695b3b08c5be1c62c0a ENV OLLAMA_MODELS=/usr/share/ollama/.ollama/models diff --git a/backend/api/emails.py b/backend/api/emails.py index 2b0a9dbd6..5cfa77a77 100644 --- a/backend/api/emails.py +++ b/backend/api/emails.py @@ -5,7 +5,7 @@ from sqlalchemy import func, or_, select from db.session import get_db from db.models import Email -from pydantic import BaseModel, EmailStr, Field, field_validator +from pydantic import BaseModel, EmailStr, Field import datetime import time from typing import Literal @@ -693,14 +693,6 @@ class SendEmailRequest(BaseModel): in_reply_to: str | None = None # O3: email threading support references: str | None = None - @field_validator("to", "subject", "in_reply_to", "references", mode="before") - @classmethod - def reject_crlf(cls, v: str | None) -> str | None: - if isinstance(v, str): - if chr(10) in v or chr(13) in v: - raise ValueError("CR/LF injection detected") - return v - @router.post("/send") async def send_email_endpoint( diff --git a/backend/api/tools.py b/backend/api/tools.py index 248996af7..eafbaaf76 100644 --- a/backend/api/tools.py +++ b/backend/api/tools.py @@ -6,7 +6,6 @@ import re import unicodedata import urllib.parse -import uuid from collections import Counter from collections.abc import Callable from typing import Any, Dict, List, Optional @@ -190,7 +189,6 @@ def _validate_parameters(self, code: str, params: Dict[str, Any]) -> Dict[str, A # Initialize default tools - async def mock_handler(params: Dict[str, Any]) -> str: encoded = json.dumps(params, ensure_ascii=False, sort_keys=True) return f"Mock execution successful with params: {encoded}" @@ -247,7 +245,6 @@ 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" @@ -275,10 +272,7 @@ async def email_translator_handler(params: Dict[str, Any]) -> Any: ] translated_terms: list[str] = [] for source_phrase, translated_phrase in phrase_map: - if ( - source_phrase in lowered_text - and translated_phrase not in translated_terms - ): + if source_phrase in lowered_text and translated_phrase not in translated_terms: translated_terms.append(translated_phrase) translated_text = " ".join(translated_terms) if translated_terms else text confidence = 0.9 if translated_terms else 0.45 @@ -297,9 +291,7 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any: normalized_domain = sender_domain.lower() phishing_terms = {"password", "bank", "login", "verify", "account", "credential"} spam_terms = {"urgent", "now", "free", "winner", "click", "limited"} - phishing_hits = sorted( - term for term in phishing_terms if term in normalized_content - ) + phishing_hits = sorted(term for term in phishing_terms if term in normalized_content) spam_hits = sorted(term for term in spam_terms if term in normalized_content) suspicious_domain = ( normalized_domain.endswith((".ru", ".zip", ".tk")) @@ -322,9 +314,7 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any: warnings.append(f"sender domain looks suspicious: {sender_domain}") return { "is_spam": bool(spam_hits or suspicious_domain), - "is_phishing": bool( - len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain) - ), + "is_phishing": bool(len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain)), "risk_score": risk_score, "warnings": warnings, } @@ -349,15 +339,7 @@ async def sentiment_analyzer_handler(params: Dict[str, Any]) -> Any: text = params.get("text", "") normalized_text = text.lower() positive_terms = {"thank", "thanks", "great", "good", "excellent", "감사", "좋"} - negative_terms = { - "disappointed", - "urgent", - "issue", - "problem", - "bad", - "불만", - "문제", - } + negative_terms = {"disappointed", "urgent", "issue", "problem", "bad", "불만", "문제"} positive_hits = [term for term in positive_terms if term in normalized_text] negative_hits = [term for term in negative_terms if term in normalized_text] if negative_hits and len(negative_hits) >= len(positive_hits): @@ -551,7 +533,6 @@ def _parameter_matches_type(value: Any, expected_type: str) -> bool: tone_analyzer_handler, ) - async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]: text = params.get("text", "") char_count = len(text) @@ -564,7 +545,6 @@ async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]: "word_count": len(text.split()), } - registry.register( ToolInfo( code="text_analyzer", @@ -841,22 +821,6 @@ async def meeting_agenda_generator_handler(params: Dict[str, Any]) -> Any: ) -async def uuid_v4_generator_handler(params: Dict[str, Any]) -> Dict[str, str]: - return {"uuid": str(uuid.uuid4())} - - -registry.register( - ToolInfo( - code="uuid_v4_generator", - name="UUID V4 생성기 (UUID v4 Generator)", - description="범용 고유 식별자(UUID) 버전 4를 무작위로 생성합니다.", - category="유틸리티", - parameters={}, - ), - uuid_v4_generator_handler, -) - - @router.get("/tools", response_model=list[ToolInfo]) def get_tools() -> list[ToolInfo]: """ diff --git a/backend/scripts/disksage_copy_readiness_handoff.py b/backend/scripts/disksage_copy_readiness_handoff.py index 1036d1bb1..e50ebde6b 100644 --- a/backend/scripts/disksage_copy_readiness_handoff.py +++ b/backend/scripts/disksage_copy_readiness_handoff.py @@ -61,10 +61,6 @@ READINESS_STATES = frozenset( {"no-candidates", "blocked", "partially-ready", "ready-without-new-review"} ) -# DiskSage schema v5 adds path-free provider-global-sync evidence while retaining the same -# success contract consumed by this handoff. Keep v3/v4 readable for already-issued evidence -# records; newer envelopes must be added here deliberately and tested. -SUPPORTED_READINESS_SCHEMA_VERSIONS = frozenset({3, 4, 5}) ERROR_CODE_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") @@ -359,7 +355,7 @@ def _decode_protocol(result: VerifierResult) -> dict[str, object]: and payload.get("ok") is True and payload.get("schema_kind") == "disksage.naruon.cloud-copy-readiness" and type(payload.get("schema_version")) is int - and payload.get("schema_version") in SUPPORTED_READINESS_SCHEMA_VERSIONS + and payload.get("schema_version") == 3 and payload.get("provider") in PROVIDERS and payload.get("readiness_state") in READINESS_STATES and type(payload.get("candidate_count")) is int diff --git a/backend/services/email_client.py b/backend/services/email_client.py index 8763a41aa..db17eb77a 100644 --- a/backend/services/email_client.py +++ b/backend/services/email_client.py @@ -64,10 +64,6 @@ class SmtpConfig: def generate_oauth2_string(user: str, access_token: str) -> bytes: """Generates an OAuth2 string for IMAP/SMTP authentication.""" - if "\x01" in user or "\x01" in access_token: - raise ValueError( - "OAuth2 authentication fields must not contain SASL delimiters" - ) auth_string = f"user={user}\x01auth=Bearer {access_token}\x01\x01" return base64.b64encode(auth_string.encode("utf-8")) diff --git a/backend/services/text_safety.py b/backend/services/text_safety.py index 451b8ca29..43d7b1b29 100644 --- a/backend/services/text_safety.py +++ b/backend/services/text_safety.py @@ -452,10 +452,6 @@ def strip_html_markup(value: str) -> str: decoded = _decode_entities(value) masked, placeholders = _mask_angle_emails(decoded) - # HTMLParser can expose the tail of the malformed ```` opener as - # literal data. Normalize that opener into an ignored comment boundary - # without deleting legitimate ``-->`` text elsewhere in user content. - masked = masked.replace("", " as text" - - @pytest.mark.parametrize( "safe_text", [ diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py index 8af3435e3..ae5c0a396 100644 --- a/backend/tests/test_tools_api.py +++ b/backend/tests/test_tools_api.py @@ -399,10 +399,9 @@ def error_handler(_params): assert records[0].exception_type == "ValueError" assert len(records[0].exception_traceback_fingerprint) == 12 int(records[0].exception_traceback_fingerprint, 16) - assert ( - records[0].tool_code_fingerprint - == hashlib.sha256(hostile_code.encode("utf-8")).hexdigest()[:12] - ) + assert records[0].tool_code_fingerprint == hashlib.sha256( + hostile_code.encode("utf-8") + ).hexdigest()[:12] assert response.message == r"failure\r\nforged_exception=true" assert "\r" not in response.message assert "\n" not in response.message @@ -504,30 +503,6 @@ async def test_text_analyzer_tool_success(): assert result["word_count"] == 6 -@pytest.mark.asyncio -async def test_uuid_v4_generator_tool_success(): - with TestClient(app) as client: - response = client.post( - "/api/tools/uuid_v4_generator/execute", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"parameters": {}}, - ) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - result = data["result"] - - # Check if the result has 'uuid' key - assert "uuid" in result - - # Validate UUID v4 format - import uuid - - generated_uuid = result["uuid"] - parsed_uuid = uuid.UUID(generated_uuid) - assert parsed_uuid.version == 4 - - @pytest.mark.asyncio async def test_base64_encoder_tool_success(): with TestClient(app) as client: diff --git a/connector/Dockerfile b/connector/Dockerfile index fa45883d0..db7e95e7e 100644 --- a/connector/Dockerfile +++ b/connector/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc +FROM python:3.14-slim@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6 WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 diff --git a/docs/operations/container-provenance-contract.md b/docs/operations/container-provenance-contract.md deleted file mode 100644 index 0d98c0863..000000000 --- a/docs/operations/container-provenance-contract.md +++ /dev/null @@ -1,41 +0,0 @@ -# Container provenance contract - -Naruon container images must be reproducible from reviewable, immutable base-image inputs. - -## Required invariants - -- Every production `FROM` instruction uses both a human-readable image tag and a full `sha256` digest. -- The root, backend, connector, and frontend Dockerfiles keep shared Python and Node base references synchronized where the runtime contract is shared. -- OCI `org.opencontainers.image.base.name` and `org.opencontainers.image.base.digest` annotations are derived from the actual first Dockerfile stage rather than duplicated constants. -- `OCI_IMAGE_BASE_DIGEST` and `OCI_IMAGE_BASE_NAME` are mandatory build arguments. Dockerfiles fail closed when a publishing or validation path omits either value. -- Published multi-platform images preserve annotations at both the manifest and index levels. -- Pull-request validation resolves the pinned Ollama manifest and fails closed when either `linux/amd64` or `linux/arm64` is absent. -- Dependency and image security pins remain governed by executable repository tests; a dependency upgrade must update its hash-locked artifact and the corresponding regression contract together. -- Backend `cryptography==50.0.0` and `protobuf==7.35.1`, Strix `cryptography==50.0.0` and `protobuf==6.33.6`, frontend source pins `postcss==8.5.24` and `jsdom==^30.0.1`, generated-lock resolutions `postcss==8.5.24` and `jsdom==30.0.1`, and the `brace-expansion==5.0.9` and `undici==8.9.0` overrides are parsed and checked structurally. - -## Change procedure - -1. Update the tag-and-digest reference in the canonical Dockerfile. -2. Synchronize every Dockerfile that shares that runtime. -3. Regenerate affected hash locks without weakening `--require-hashes` installation. -4. Update `CHANGELOG.md` when the runtime or published artifact changes. -5. Run release-governance, repository-hygiene, dependency-pin, application, image-build, and security checks on the exact pull-request head. -6. Merge only after independent review confirms that the OCI annotations describe the image that is actually built. - -A mutable tag by itself, a digest without its reviewable tag, an omitted mandatory base-metadata argument, or an annotation that does not match the first stage violates this contract. - -## Standards interpretation - -The OCI Image Format is the authoritative interoperability contract for image manifests, indexes, configurations, and descriptors. Naruon derives its base-image annotations from the Dockerfile actually used for the build so the published metadata cannot silently diverge from the reviewed build input. - -SLSA Build Provenance 1.2 describes provenance as verifiable information about where, when, and how an artifact was produced. It treats externally supplied build parameters as untrusted inputs that must be recorded and verified downstream. Naruon's tag-and-digest base references, exact workflow revision, and generated dependency locks are therefore reviewable build inputs rather than decorative metadata. This repository does not claim a SLSA level solely because it emits OCI annotations. - -NIST SP 800-218, SSDF 1.1, recommends protecting software and verifying third-party components throughout the development and delivery lifecycle. Naruon implements that guidance through immutable action and image pins, generated hash locks, exact-head tests, vulnerability scans, and independent review. The newer SSDF 1.2 document remains an initial public draft as of August 2026 and is informative rather than the formal conformance baseline. - -## References - -National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 - -Open Container Initiative. (2025). *OCI image format specification* (Version 1.1.1). https://github.com/opencontainers/image-spec/tree/v1.1.1 - -Supply-chain Levels for Software Artifacts. (2025). *Build provenance* (SLSA specification Version 1.2). https://slsa.dev/spec/v1.2/build-provenance diff --git a/docs/research/email-authentication-xoauth2/README.md b/docs/research/email-authentication-xoauth2/README.md deleted file mode 100644 index d764ddf66..000000000 --- a/docs/research/email-authentication-xoauth2/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Email authentication — XOAUTH2 delimiter integrity - -This note grounds Naruon's SASL XOAUTH2 payload construction at -`backend/services/email_client.py` and the hostile-input regression in -`backend/tests/test_email_client.py`. - -## Protocol boundary - -RFC 7628 defines OAuth SASL key/value fields as being separated by the octet -`%x01` (Control-A). Google's Gmail XOAUTH2 documentation uses the same wire -shape for the initial client response: one `user` field, one -`auth=Bearer ...` field, and a final empty field, each separated by Control-A. -The delimiter is therefore protocol structure, not ordinary caller-controlled -field data. - -Naruon's helper previously interpolated the supplied user identity and access -token into that attribute stream before base64 encoding. A Control-A embedded -inside either value created an additional protocol field boundary. Base64 does -not remove that ambiguity; it only encodes the already-constructed octet -sequence. - -## Decision - -`generate_oauth2_string()` rejects `\x01` in either the user identity or access -token before the SASL response is constructed. The ordinary response format is -unchanged. The function does not log credentials, repair malformed values, -percent-encode the delimiter, introduce a fallback authentication mechanism, or -broaden the allowed IMAP/SMTP destinations. - -The regression corpus covers delimiter injection through both caller-controlled -fields and preserves the existing valid-payload test. This is a structural -protocol validation rule rather than a keyword/security-score heuristic. - -## Claim boundary - -This change prevents caller data from introducing extra XOAUTH2 field -separators at this construction boundary. It does not by itself claim complete -OAuth, SASL, Gmail, IMAP, or SMTP security; token issuance, audience/scope, -transport security, server policy, credential storage, TLS identity, egress -allowlisting, and provider behavior remain separate controls. - -## References (APA 7) - -- Mills, W., Showalter, T., & Tschofenig, H. (2015). *A set of Simple - Authentication and Security Layer (SASL) mechanisms for OAuth* (RFC 7628). - RFC Editor. https://www.rfc-editor.org/rfc/rfc7628.html -- Google. (n.d.). *OAuth 2.0 mechanism*. Google Workspace. Retrieved August 14, - 2026, from https://developers.google.com/workspace/gmail/imap/xoauth2-protocol - -## Verification boundary - -The branch is not merge-ready merely because this note and the narrow fix -exist. Current-head repository CI, security, coverage, independent review, and -protected-branch gates remain authoritative. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b33546053..770d713e7 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:26-slim@sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503 +FROM node:26-slim@sha256:ffc78385a788964bb3cbab5e434ff79a10bdc25b8ae6db03fe5fe6cb14053c09 ARG OCI_IMAGE_CREATED="" ARG OCI_IMAGE_AUTHORS="Seongho Bae" @@ -12,13 +12,8 @@ ARG OCI_IMAGE_LICENSES="LicenseRef-Naruon-Proprietary" ARG OCI_IMAGE_REF_NAME="" ARG OCI_IMAGE_TITLE="naruon frontend" ARG OCI_IMAGE_DESCRIPTION="Naruon Next.js frontend runtime image" -ARG OCI_IMAGE_BASE_DIGEST="sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503" -ARG OCI_IMAGE_BASE_NAME="docker.io/library/node:26-slim@sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503" - -# Defaults keep local builds provenance-complete. The release workflow derives -# and overrides both values from this file's exact FROM line, while repository -# governance tests prevent the reviewed defaults from drifting. -RUN test -n "$OCI_IMAGE_BASE_DIGEST" && test -n "$OCI_IMAGE_BASE_NAME" +ARG OCI_IMAGE_BASE_DIGEST="sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" +ARG OCI_IMAGE_BASE_NAME="docker.io/library/node:26-slim@sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" LABEL org.opencontainers.image.created="${OCI_IMAGE_CREATED}" \ org.opencontainers.image.authors="${OCI_IMAGE_AUTHORS}" \ diff --git a/frontend/dev.log b/frontend/dev.log new file mode 100644 index 000000000..22948417f --- /dev/null +++ b/frontend/dev.log @@ -0,0 +1,61 @@ + +> frontend@0.1.0 dev +> next dev + +▲ Next.js 16.2.6 (Turbopack) +- Local: http://localhost:18080 +- Network: http://169.254.23.164:18080 +✓ Ready in 377ms + + GET / 200 in 468ms (next.js: 121ms, application-code: 347ms) + GET / 200 in 473ms (next.js: 160ms, application-code: 313ms) + GET / 200 in 471ms (next.js: 165ms, application-code: 305ms) + GET / 200 in 479ms (next.js: 369ms, application-code: 110ms) +⚠ Blocked cross-origin request to Next.js dev resource /_next/webpack-hmr from "127.0.0.1". +Cross-origin access to Next.js dev resources is blocked by default for safety. + +To allow this host in development, add it to "allowedDevOrigins" in next.config.js and restart the dev server: + +// next.config.js +module.exports = { + allowedDevOrigins: ['127.0.0.1'], +} + +Read more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins + GET / 200 in 42ms (next.js: 2ms, application-code: 40ms) + GET / 200 in 106ms (next.js: 4ms, application-code: 103ms) + GET / 200 in 67ms (next.js: 3ms, application-code: 63ms) + GET / 200 in 77ms (next.js: 1403µs, application-code: 75ms) + GET / 200 in 79ms (next.js: 33ms, application-code: 46ms) + GET /settings 200 in 403ms (next.js: 365ms, application-code: 38ms) + GET / 200 in 89ms (next.js: 4ms, application-code: 85ms) + GET / 200 in 91ms (next.js: 36ms, application-code: 54ms) + GET / 200 in 32ms (next.js: 1153µs, application-code: 31ms) + GET / 200 in 31ms (next.js: 1918µs, application-code: 29ms) + GET / 200 in 30ms (next.js: 1244µs, application-code: 29ms) + GET / 200 in 78ms (next.js: 2ms, application-code: 75ms) + GET / 200 in 56ms (next.js: 1794µs, application-code: 54ms) + GET / 200 in 56ms (next.js: 1966µs, application-code: 54ms) + GET / 200 in 32ms (next.js: 984µs, application-code: 31ms) + GET / 200 in 69ms (next.js: 1080µs, application-code: 68ms) + GET / 200 in 71ms (next.js: 11ms, application-code: 60ms) + GET / 200 in 31ms (next.js: 1382µs, application-code: 30ms) + GET / 200 in 68ms (next.js: 3ms, application-code: 65ms) + GET / 200 in 69ms (next.js: 29ms, application-code: 40ms) + GET / 200 in 29ms (next.js: 963µs, application-code: 28ms) + GET / 200 in 31ms (next.js: 1061µs, application-code: 30ms) + GET / 200 in 78ms (next.js: 1659µs, application-code: 76ms) + GET / 200 in 51ms (next.js: 2ms, application-code: 49ms) + GET / 200 in 29ms (next.js: 1263µs, application-code: 28ms) + GET / 200 in 80ms (next.js: 1308µs, application-code: 78ms) + GET / 200 in 51ms (next.js: 1566µs, application-code: 49ms) + GET / 200 in 44ms (next.js: 1701µs, application-code: 42ms) + GET /mail 200 in 129ms (next.js: 24ms, application-code: 106ms) + GET /mail 200 in 139ms (next.js: 37ms, application-code: 102ms) + GET / 200 in 31ms (next.js: 1002µs, application-code: 30ms) + GET / 200 in 30ms (next.js: 1048µs, application-code: 29ms) + GET /calendar 200 in 440ms (next.js: 336ms, application-code: 104ms) + GET /calendar 200 in 448ms (next.js: 352ms, application-code: 96ms) + GET / 200 in 45ms (next.js: 1926µs, application-code: 43ms) + GET /tasks 200 in 285ms (next.js: 205ms, application-code: 80ms) + GET /tasks 200 in 280ms (next.js: 189ms, application-code: 91ms) diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx index db2b617b6..a36eeaad5 100644 --- a/frontend/src/components/EmailDetail.test.tsx +++ b/frontend/src/components/EmailDetail.test.tsx @@ -349,22 +349,6 @@ describe("EmailDetail", () => { expect(container.textContent).toContain("Thread B sibling body"); expect(container.textContent).toContain("2개 메시지"); expect(container.textContent).not.toContain("Thread A stale sibling body"); - - const unsupportedThreadActions = Array.from( - container.querySelectorAll("button"), - ).filter((button) => { - const accessibleName = [ - button.textContent, - button.getAttribute("aria-label"), - button.getAttribute("title"), - ] - .filter((value): value is string => Boolean(value)) - .join(" "); - return ["다른 스레드 병합", "스레드 분리"].some((label) => - accessibleName.includes(label), - ); - }); - expect(unsupportedThreadActions).toHaveLength(0); }); it("renders 맥락 종합, action items, and reply drafting in reusable 판단 포인트 cards", async () => { diff --git a/frontend/src/components/EmailDetail.tsx b/frontend/src/components/EmailDetail.tsx index e634a896c..35263d783 100644 --- a/frontend/src/components/EmailDetail.tsx +++ b/frontend/src/components/EmailDetail.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState, memo } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { apiClient } from '@/lib/api-client'; import { Separator } from "@/components/ui/separator"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -102,10 +102,7 @@ function normalizeLlmData(payload: unknown): LlmData { }; } -// ⚡ Bolt: Memoized EmailDetail to prevent unnecessary re-renders -// 🎯 Why: Re-renders of EmailDetail when the parent components (like WorkspaceHome) re-render can cause performance issues, especially when switching active layout tabs or receiving polling updates that don't affect the selected email. -// 📊 Impact: Significantly reduces React reconciliation work when the workspace state changes but the selected email remains the same. -export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand = null }: { emailId: number | null; actionCommand?: EmailDetailActionCommand | null }) { +export function EmailDetail({ emailId, actionCommand = null }: { emailId: number | null; actionCommand?: EmailDetailActionCommand | null }) { const [email, setEmail] = useState(null); const [threadEmails, setThreadEmails] = useState([]); const [llmData, setLlmData] = useState(null); @@ -754,6 +751,9 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand = {conversationMessages.length}개 메시지 +

오래된 메시지부터 최신 메시지 순서로 보여줍니다. 답장은 선택된 메시지를 기준으로 작성됩니다.

{threadLoading &&

대화 흐름을 불러오는 중입니다...

} @@ -770,6 +770,11 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand = {toMailDisplayText(msg.sender, '보낸 사람')}
{formatEmailDate(msg.date)} + {msg.id !== conversationMessages[0]?.id && ( + + )}
{msg.id === email.id && 선택된 메시지} @@ -878,4 +883,4 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand = /> ); -}); +} diff --git a/frontend/src/components/NetworkGraph.tsx b/frontend/src/components/NetworkGraph.tsx index d33dc04fd..c470ff855 100644 --- a/frontend/src/components/NetworkGraph.tsx +++ b/frontend/src/components/NetworkGraph.tsx @@ -132,15 +132,9 @@ function findNodeLabel(nodes: Node[], id: number | string) { return String(node?.label ?? id); } -function describeEdge(edge: Edge, nodes: Node[], nodeMap?: Map) { - let fromLabel, toLabel; - if (nodeMap) { - fromLabel = nodeMap.get(String(edge.from)) ?? String(edge.from); - toLabel = nodeMap.get(String(edge.to)) ?? String(edge.to); - } else { - fromLabel = findNodeLabel(nodes, edge.from); - toLabel = findNodeLabel(nodes, edge.to); - } +function describeEdge(edge: Edge, nodes: Node[]) { + const fromLabel = findNodeLabel(nodes, edge.from); + const toLabel = findNodeLabel(nodes, edge.to); const title = titleText(edge.title); return title ? `${fromLabel} -> ${toLabel} (${title})` : `${fromLabel} -> ${toLabel}`; } @@ -159,16 +153,6 @@ export default function NetworkGraph() { const [graphActionStatus, setGraphActionStatus] = useState('그래프 준비 완료'); const [relationshipOptionId, setRelationshipOptionId] = useState(''); const [nodeOptionId, setNodeOptionId] = useState(''); - const nodeMap = useMemo(() => { - const map = new Map(); - for (const node of nodes) { - const key = String(node.id); - if (!map.has(key)) { - map.set(key, String(node.label ?? node.id)); - } - } - return map; - }, [nodes]); useEffect(() => { apiClient.get('/api/network/graph') @@ -206,7 +190,7 @@ export default function NetworkGraph() { if (!edge) return; setRelationshipOptionId(String(edge.id)); setNodeOptionId(''); - setSelectedGraphDetail(`선택된 관계: ${describeEdge(edge, nodes, nodeMap)}`); + setSelectedGraphDetail(`선택된 관계: ${describeEdge(edge, nodes)}`); setGraphActionStatus('그래프에서 관계를 선택했습니다.'); }; @@ -262,7 +246,7 @@ export default function NetworkGraph() { network.destroy(); }; } - }, [nodes, edges, nodeMap]); + }, [nodes, edges]); const nodeLabels = useMemo(() => { return nodes @@ -276,9 +260,9 @@ export default function NetworkGraph() { return edges.slice(0, 5).map((edge, index) => ({ edge, id: String(edge.id), - label: `관계 ${index + 1}: ${describeEdge(edge, nodes, nodeMap)}`, + label: `관계 ${index + 1}: ${describeEdge(edge, nodes)}`, })); - }, [edges, nodes, nodeMap]); + }, [edges, nodes]); const nodeOptions = useMemo(() => { return nodes.slice(0, 8).map((node) => ({ @@ -291,7 +275,7 @@ export default function NetworkGraph() { const selectRelationship = (edge: Edge, status: string) => { setRelationshipOptionId(String(edge.id)); setNodeOptionId(''); - setSelectedGraphDetail(`선택된 관계: ${describeEdge(edge, nodes, nodeMap)}`); + setSelectedGraphDetail(`선택된 관계: ${describeEdge(edge, nodes)}`); setGraphActionStatus(status); if (isGraphId(edge.id)) { networkRef.current?.selectEdges?.([edge.id]); diff --git a/frontend/src/components/TasksLayout.tsx b/frontend/src/components/TasksLayout.tsx index e2f94a027..034aa6911 100644 --- a/frontend/src/components/TasksLayout.tsx +++ b/frontend/src/components/TasksLayout.tsx @@ -367,28 +367,7 @@ export function TasksLayout() { ), [currentColumns, tasksByStatus, taskSearch, priorityFilter, setSelectedTaskId, setViewMode]); - - // ⚡ Bolt: Wrap My Tasks list in useMemo to prevent O(N) re-renders - // 🎯 Why: Mapping over potentially large lists of filtered tasks blocks the main thread during unrelated state updates. - const myTasksList = useMemo(() => { - if (viewMode !== '내 작업') return null; - return filteredTicketTasks.length > 0 ? filteredTicketTasks.map(task => ( - - )) : ( -

서명 세션에 연결된 내 작업이 없습니다.

- ); - }, [filteredTicketTasks, setSelectedTaskId, setViewMode, viewMode]); const handleViewModeKeyDown = (event: KeyboardEvent, mode: TaskViewMode) => { - const currentIndex = TASK_VIEW_MODES.indexOf(mode); const lastIndex = TASK_VIEW_MODES.length - 1; let nextIndex: number; @@ -705,7 +684,20 @@ export function TasksLayout() { {viewMode === '내 작업' && (

내 작업

- {myTasksList} + {filteredTicketTasks.length > 0 ? filteredTicketTasks.map(task => ( + + )) : ( +

서명 세션에 연결된 내 작업이 없습니다.

+ )}
)} diff --git a/test_parse.py b/test_parse.py deleted file mode 100644 index 374a3c09b..000000000 --- a/test_parse.py +++ /dev/null @@ -1,24 +0,0 @@ -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent / "backend")) -from services.text_safety import _strip_tag_like_segments, _PlainTextHTMLParser - - -def main() -> None: - parser = _PlainTextHTMLParser() - parser.feed("-->") - parser.close() - text = parser.get_text() - print("Parsed text:", repr(text)) - print("Strip tag like segments:", repr(_strip_tag_like_segments(text))) - - # also look at what the parser does with - parser2 = _PlainTextHTMLParser() - parser2.feed("") - parser2.close() - print("Parsed :", repr(parser2.get_text())) - - -if __name__ == "__main__": - main() diff --git a/test_parse2.py b/test_parse2.py deleted file mode 100644 index 76c435252..000000000 --- a/test_parse2.py +++ /dev/null @@ -1,14 +0,0 @@ -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent / "backend")) -from services.text_safety import strip_html_markup - - -def main() -> None: - payload = "-->" - print(repr(strip_html_markup(payload))) - - -if __name__ == "__main__": - main() diff --git a/test_parse3.py b/test_parse3.py deleted file mode 100644 index cbdec66c1..000000000 --- a/test_parse3.py +++ /dev/null @@ -1,33 +0,0 @@ -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent / "backend")) -from services.text_safety import _mask_angle_emails, _PlainTextHTMLParser, _strip_tag_like_segments - - -def main() -> None: - payload = "-->" - - decoded = payload - masked, placeholders = _mask_angle_emails(decoded) - print("masked:", repr(masked)) - parser = _PlainTextHTMLParser() - parser.feed(masked) - parser.close() - text = parser.get_text() - print("text after parser get_text (normalized):", repr(text)) - - print("after get_text but raw joins:", repr("".join(parser._parts))) - print("just _strip_tag_like_segments directly on parser._parts:", _strip_tag_like_segments("".join(parser._parts))) - - cleaned_lines = [] - for line in text.splitlines(): - cleaned_lines.append(_strip_tag_like_segments(line)) - text = "\n".join(cleaned_lines).strip() - for token, original in placeholders.items(): - text = text.replace(token, original) - print("text after second loop:", repr(text)) - - -if __name__ == "__main__": - main() From 588b3d14444f85c6cd90f8fb89f3c3b3df44a02f Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:57:30 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=B2=84=ED=8A=BC=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EC=9E=91=EC=97=85=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EB=B0=B1=EC=97=94=EB=93=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/text_safety.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/services/text_safety.py b/backend/services/text_safety.py index 43d7b1b29..71629f866 100644 --- a/backend/services/text_safety.py +++ b/backend/services/text_safety.py @@ -376,6 +376,16 @@ def _strip_tag_like_segments(value: str) -> str: parts.append(value[cursor:]) break + # Check for HTML comments + if value.startswith("", start + 4) + if end == -1: + parts.append(value[cursor:start]) + break + parts.append(value[cursor:start]) + cursor = end + 3 + continue + end = value.find(">", start + 1) if end == -1: candidate = value[start + 1 :] From c73e245d2066e91841d9169f0386946ce2437493 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:04:21 +0000 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EB=B2=84=ED=8A=BC=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EC=9E=91=EC=97=85=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20HTML=20=EB=A7=88=ED=81=AC=EC=97=85=20?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=BD=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 80ceab86de5ed550ab1bad371d010987591f0c6f Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:19:01 +0000 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EB=B2=84=ED=8A=BC=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EC=9E=91=EC=97=85=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20HTML=20=EB=A7=88=ED=81=AC=EC=97=85=20?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=BD=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit