Skip to content

feat(asv3): markdown port-only path + safeHref tighten (WP-4)#407

Open
Luis85 wants to merge 2 commits into
developfrom
claude/asv3-wp04-markdown-hardening
Open

feat(asv3): markdown port-only path + safeHref tighten (WP-4)#407
Luis85 wants to merge 2 commits into
developfrom
claude/asv3-wp04-markdown-hardening

Conversation

@Luis85
Copy link
Copy Markdown
Owner

@Luis85 Luis85 commented May 17, 2026

Summary

  • Port-only renderer for completed turnsMarkdownBlock.vue drops from 458 → 205 LOC. The hand-rolled VNode parser branch is gone; the SFC now delegates exclusively to MARKDOWN_RENDER_PORT and throws at mount if the port is missing (port-only invariant closed). The extracted pure parser lives at src/ui/components/agent/internal/markdown-parser.ts.
  • Streaming bypass — new streaming?: boolean prop. MessageList.vue passes :streaming="true" for the in-flight bubble; the template renders <pre>{{ text }}</pre> (Vue interpolation, white-space: pre-wrap) with zero markdown parsing — no per-token re-mount flicker, no port round-trip. The parent flips to false on stream complete and the port renders the final tree once.
  • safeHref tightened to an explicit deny → allow → default-reject pipeline. Rejects javascript:, data:, file:, blob:, vbscript:, about:, chrome:, chrome-extension:, obsidian: (whitespace- and case-tolerant), plus protocol-relative //host. Accepts https:, http:, mailto:, /root/relative, #fragment. Tested with a full rejection table.
  • New adaptersMockMarkdownRenderPort (src/infrastructure/mock/) and LocalStorageMarkdownRenderPort (src/infrastructure/localstorage/, delegates to mock) so the port is always present in test / dev / GitHub Pages builds. src/ui/main.ts wires them via app.provide(MARKDOWN_RENDER_PORT, …).
  • max-lines warning on MarkdownBlock.vue is gone (205 LOC, below the 350 threshold) — addresses the WP-4 audit finding.

Test plan

DoD checklist from specs/agent-sidepanel-v3/wp-04-markdown-hardening/brief.md:

  • npm audit --audit-level=high --omit=dev — clean (0 vulnerabilities).
  • npm run typecheck — clean.
  • npm run lint — 0 errors; max-lines warning on MarkdownBlock.vue is gone.
  • npm run test1915 passed (151 files).
  • npm run build — succeeds; regenerates styles.css (scoped Vue styles, included in commit).
  • npm run build:web — succeeds.
  • npm run docs:api — succeeds.
  • Streaming bypass closedMessageList.test.ts asserts that when streamingText.length > 0 the rendered DOM is a <pre> (no markdown parsing). After stream complete, the DOM is the port-rendered tree.
  • safeHref table closedtests/ui/components/agent/internal/markdown-parser.test.ts rejection table covers javascript:, data:, file:, blob:, vbscript:, about:, chrome:, chrome-extension:, obsidian:, and protocol-relative //host; acceptance of https:, http:, mailto:, /root/relative, #fragment.
  • Port-only invariant — the inject(MARKDOWN_RENDER_PORT, undefined) fallback is gone; MarkdownBlock throws at mount if the port is missing, asserted in MarkdownBlock.test.ts.

Audit rationale: MarkdownBlock.vue carried two implementations (Obsidian renderer + hand-rolled VNode parser) and re-mounted the native renderer per token delta during streaming — both flagged by the WP-4 audit alongside the max-lines warning and the safeHref allowlist gap (protocol-relative //host slipped through the /-prefix branch). This PR closes all three.

https://claude.ai/code/session_01UWDtzLuFJU3QmQmLrXCxWj


Generated by Claude Code

claude added 2 commits May 17, 2026 13:34
… tighten (WP-4)

Make `MarkdownRenderPort` the only renderer for completed assistant turns,
bypass the port entirely while a turn is still streaming, and tighten
`safeHref` so the rendered link surface cannot smuggle unsafe URI schemes.

New port adapters & pure parser

- `src/ui/components/agent/internal/markdown-parser.ts` — extracts the
  hand-rolled paragraph / code-fence / list / blockquote / inline
  bold-italic-code-link rendering that used to live inside
  `MarkdownBlock.vue` into a pure module exporting `renderMarkdownInto` and
  `safeHref`. The adapters write real DOM (no `innerHTML`, ADR-008).
- `src/infrastructure/mock/MockMarkdownRenderPort.ts` — unit-test and
  `npm run dev` adapter, delegates to the pure parser.
- `src/infrastructure/localstorage/LocalStorageMarkdownRenderPort.ts` —
  GitHub Pages adapter, delegates to the mock so the three-bridge symmetry
  is preserved (`ObsidianBridge` keeps its native Obsidian-renderer adapter).
- `src/ui/main.ts` — wires the new port via `app.provide(MARKDOWN_RENDER_PORT, …)`
  in both PROD (LocalStorage) and dev (Mock) modes so the port is always
  present in the standalone build.

MarkdownBlock simplification

- `src/ui/components/agent/MarkdownBlock.vue` — drops from 458 LOC to 205.
  The hand-rolled VNode parser branch is gone; the SFC now delegates to
  `MARKDOWN_RENDER_PORT` exclusively. Throws at mount if the port is
  missing (port-only invariant closed). Monotonic `latestSeq` guards
  against out-of-order async port renders disposing the wrong tree.
  `max-lines` ESLint warning on this file is gone.

safeHref tightening with rejection table

- `safeHref` is now an explicit deny → allow → default-reject pipeline:
  rejects `javascript:`, `data:`, `file:`, `blob:`, `vbscript:`, `about:`,
  `chrome:`, `chrome-extension:`, `obsidian:` (whitespace- and
  case-tolerant) plus protocol-relative `//host`. Accepts `https:`,
  `http:`, `mailto:`, `/root/relative`, `#fragment`. Default-rejects
  everything else. Unit-tested with a full rejection table in
  `tests/ui/components/agent/internal/markdown-parser.test.ts`.

Scroll / streaming bypass

- New `streaming?: boolean` prop on `MarkdownBlock`. When `true`, the
  template renders `<pre class="sp-markdown--streaming">{{ text }}</pre>`
  with `white-space: pre-wrap` and zero markdown parsing — pure text via
  Vue interpolation, no per-token reparse / re-mount flicker, no port
  round-trip. On stream-complete the parent flips it to `false` and the
  port renders the final tree once.
- `MessageList.vue:363` passes `:streaming="true"` for the in-flight
  bubble; completed turns keep the port-rendered tree.

Tests

- `tests/ui/components/agent/internal/markdown-parser.test.ts` — parser
  + safeHref rejection table.
- `tests/infrastructure/mock/MockMarkdownRenderPort.test.ts` — adapter
  contract test.
- `MarkdownBlock.test.ts` — drops fallback-branch tests, adds
  streaming-prop coverage and port-only invariant assertion.
- `MessageList.test.ts` / `MessageList.compactBoundary.test.ts` /
  `InlinePlanApprovalCard.test.ts` — updated to provide the new port.

Full AGENTS.md §3 pre-PR gate green: audit / typecheck / lint / 1915 tests
passed / build / build:web / docs:api.

https://claude.ai/code/session_01UWDtzLuFJU3QmQmLrXCxWj
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants