Skip to content
Merged
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
3 changes: 3 additions & 0 deletions apps/web/src/chat/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,22 @@ export function Markdown({
className = CHAT_PROSE,
remarkPlugins,
rehypePlugins,
urlTransform,
components,
}: {
text: string;
className?: string;
remarkPlugins?: ComponentProps<typeof ReactMarkdown>["remarkPlugins"];
rehypePlugins?: ComponentProps<typeof ReactMarkdown>["rehypePlugins"];
urlTransform?: ComponentProps<typeof ReactMarkdown>["urlTransform"];
components?: ComponentProps<typeof ReactMarkdown>["components"];
}) {
return (
<div className={className}>
<ReactMarkdown
remarkPlugins={remarkPlugins ? [remarkGfm, ...remarkPlugins] : [remarkGfm]}
rehypePlugins={rehypePlugins}
urlTransform={urlTransform}
components={{ code: CodeBlock, a: Anchor, table: Table, ...components }}
>
{text}
Expand Down
25 changes: 20 additions & 5 deletions apps/web/src/chat/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,21 @@ blocks in order into rows; `ChatTurnView` dispatches on row kind:
summarized disappear immediately rather than only after reload. Its `summary` opens on click
(`data-testid="chat-compaction"`). Hydration/reopen starts directly from that same durable form. Both forms
share the **"Context compacted"** title; only facts unavailable after reload disappear.
- `markdown` — a non-empty assistant text block (react-markdown + remark-gfm + shiki). A fenced
- `markdown` — a non-empty assistant text block (react-markdown + remark-gfm + shiki). Safe
worktree-relative links in assistant prose open the target in ThinkRail through `ChatTurnView`'s existing
workspace-file callback; absolute paths are accepted only when they normalize inside the active worktree,
while URL schemes, protocol-relative URLs, fragments, and unsafe/outside paths retain ordinary safe
new-tab anchor behavior. Percent-encoded file paths are decoded once before validation, so encoded
separators and traversal cannot bypass containment. A narrow assistant-only URL transform preserves
recognized Windows drive-letter anchor paths, including Markdown's percent-encoded backslash form, until
validation; drive-rooted containment compares case-insensitively while preserving the linked path's casing.
Every other value delegates to react-markdown's default sanitizer, and a rejected Windows path
is re-sanitized before fallback anchor rendering. The generic `Markdown` primitive remains props-driven and
receives this behavior only as an `a` component override at the assistant-turn integration edge;
accepted workspace targets render as button controls without a raw browser `href`, so alternate native
anchor activation cannot escape into the SPA fallback. That override keeps a stable component identity
while its workspace inputs are unchanged: workbench focus can rerender a chat row between pointer-down and
click, and replacing the control in that interval cancels activation. A fenced
```mermaid block renders as a themed diagram via `tools/visualize`'s `MermaidView` (fullscreen
pan-zoom, error → source fallback) — uniform across every `Markdown` surface (chat, file/specs
preview); until mounted it renders as highlighted source, so static contexts (`RenderedDiff`'s
Expand Down Expand Up @@ -879,14 +893,15 @@ from their `toolCall` args and reply through **`ChatActions`** (see below). Work

## Boundary

- **Public surface:** the registry API (`toolRegistry`), the props-driven slash-completion primitive, and
the renderers (incl. the presentational `Markdown` — GFM + shiki, no store/transport; the rendering is fixed but the **prose skin** is the
- **Public surface:** the registry API (`toolRegistry`), the shared workspace-file target canonicalizer
(`fileTargets`), the props-driven slash-completion primitive, and the renderers (incl. the presentational
`Markdown` — GFM + shiki, no store/transport; the rendering is fixed but the **prose skin** is the
caller's via an optional `className` — chat uses the compact bubble skin (`tr-prose-chat`),
`panels/MarkdownPreview` the document skin (`tr-prose-doc`). A skin names exactly one generated
`tr-prose-*` system and then carries only spacing/measure/chrome — no size, weight, leading or
tracking (see `styles/TYPOGRAPHY.md`); a caller may
also **extend** the render with extra `remarkPlugins` + `components`, e.g. the file view's GitHub
alert callouts), the view types
also **extend** the render with an optional `urlTransform`, extra `remarkPlugins`, and `components`, e.g.
the file view's GitHub alert callouts), the view types
(`types.ts`,
incl. `ToolResultState` + `ExtUiDialogRequest`), and `ChatView` (lazy-mounted by the shell workbench
resource renderer;
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/chat/assistantLinks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { AssistantMarkdown, assistantFileTarget, assistantUrlTransform } from "./assistantLinks";

describe("assistantFileTarget", () => {
test("accepts normalized relative paths and absolute paths inside the workspace", () => {
expect(assistantFileTarget(".thinkrail/context/report.md", "/repo")).toBe(
".thinkrail/context/report.md",
);
expect(assistantFileTarget("docs/../README.md#usage", "/repo")).toBe("README.md");
expect(assistantFileTarget("reports/manual%20report.md", "/repo")).toBe(
"reports/manual report.md",
);
expect(assistantFileTarget("/repo/docs/report.md?raw=1", "/repo")).toBe("docs/report.md");
expect(assistantFileTarget("c:/repo/docs/Report.md", "C:/Repo")).toBe("docs/Report.md");
});

test("rejects external, fragment, outside-workspace, and context-free targets", () => {
for (const href of [
"https://example.com/report",
"mailto:owner@example.com",
"//example.com/report",
"#report",
"../report.md",
"%2e%2e/report.md",
"/tmp/report.md",
"report%E0%A4%A.md",
]) {
expect(assistantFileTarget(href, "/repo")).toBeNull();
}
expect(assistantFileTarget("README.md", undefined)).toBeNull();
});
});

test("assistant URL transforms preserve only anchor-shaped Windows paths", () => {
const anchor = { tagName: "a" };
expect(assistantUrlTransform("C:/repo/report.md", "href", anchor)).toBe("C:/repo/report.md");
expect(assistantUrlTransform("C:%5Crepo%5Creport.md", "href", anchor)).toBe(
"C:%5Crepo%5Creport.md",
);
expect(assistantUrlTransform("C:/repo/image.png", "src", { tagName: "img" })).toBe("");
expect(assistantUrlTransform("C:/repo/style.css", "href", { tagName: "link" })).toBe("");
expect(assistantUrlTransform("javascript:alert(1)", "href", anchor)).toBe("");
expect(assistantUrlTransform("https://example.com/report", "href", anchor)).toBe(
"https://example.com/report",
);
});

test("assistant Markdown distinguishes workspace files from ordinary anchors", () => {
const html = renderToStaticMarkup(
<AssistantMarkdown
text={[
"[report](<.thinkrail/context/manual report.md>)",
"[site](https://example.com/report)",
"[outside](../report.md)",
].join("\n\n")}
workspaceRoot="/repo"
onOpenFile={() => {}}
/>,
);

expect(html).toContain('<button type="button" data-testid="chat-file-link"');
expect(html).toContain('data-path=".thinkrail/context/manual report.md"');
expect(html).not.toContain('href=".thinkrail/context/manual%20report.md"');
expect(html.match(/target="_blank"/g)).toHaveLength(2);
expect(html).toContain('href="https://example.com/report"');
expect(html).toContain('href="../report.md"');
});

test("assistant Markdown opens contained Windows paths without exposing unsafe URLs", () => {
const html = renderToStaticMarkup(
<AssistantMarkdown
text={[
"[inside](c:/repo/docs/Report.md)",
"[inside backslash](c:\\REPO\\docs\\backslash.md)",
"[outside](D:/other/report.md)",
"[unsafe](javascript:alert(1))",
].join("\n\n")}
workspaceRoot="C:/Repo"
onOpenFile={() => {}}
/>,
);

expect(html.match(/data-testid="chat-file-link"/g)).toHaveLength(2);
expect(html).toContain('data-path="docs/Report.md"');
expect(html).toContain('data-path="docs/backslash.md"');
expect(html).not.toContain('href="c:/repo/docs/Report.md"');
expect(html).not.toContain('href="c:%5CREPO%5Cdocs%5Cbackslash.md"');
expect(html).not.toContain('href="D:/other/report.md"');
expect(html).not.toContain('href="javascript:alert(1)"');
});
91 changes: 91 additions & 0 deletions apps/web/src/chat/assistantLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { type ReactNode, useMemo } from "react";
import { type Components, defaultUrlTransform } from "react-markdown";
import { isWindowsAbsolutePath, workspaceFileTarget } from "./fileTargets";
import { Markdown } from "./Markdown";

export function assistantUrlTransform(
value: string,
property: string,
node: { tagName: string },
): string {
if (property === "href" && node.tagName === "a") {
try {
if (isWindowsAbsolutePath(decodeURIComponent(value))) return value;
} catch {
return defaultUrlTransform(value);
}
}
return defaultUrlTransform(value);
}

export function assistantFileTarget(
href: string | undefined,
workspaceRoot: string | undefined,
): string | null {
const candidate = href?.trim();
if (!candidate || !workspaceRoot || candidate.startsWith("#") || candidate.startsWith("//")) {
return null;
}
const encodedPath = candidate.split(/[?#]/, 1)[0];
if (!encodedPath) return null;
try {
return workspaceFileTarget(decodeURIComponent(encodedPath), workspaceRoot);
} catch {
return null;
}
}

function AssistantLink({
href,
children,
workspaceRoot,
onOpenFile,
}: {
href?: string | undefined;
children?: ReactNode;
workspaceRoot?: string | undefined;
onOpenFile?: ((path: string) => void) | undefined;
}) {
const target = assistantFileTarget(href, workspaceRoot);
if (!target || !onOpenFile) {
const safeHref = href === undefined ? undefined : defaultUrlTransform(href);
return (
<a href={safeHref} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
return (
<button
type="button"
data-testid="chat-file-link"
data-path={target}
onClick={() => onOpenFile(target)}
className="cursor-pointer text-left text-primary underline"
>
{children}
</button>
);
}

export function AssistantMarkdown({
text,
workspaceRoot,
onOpenFile,
}: {
text: string;
workspaceRoot?: string | undefined;
onOpenFile?: ((path: string) => void) | undefined;
}) {
const components = useMemo<Components>(
() => ({
a: ({ href, children }) => (
<AssistantLink href={href} workspaceRoot={workspaceRoot} onOpenFile={onOpenFile}>
{children}
</AssistantLink>
),
}),
[workspaceRoot, onOpenFile],
);
return <Markdown text={text} urlTransform={assistantUrlTransform} components={components} />;
}
19 changes: 19 additions & 0 deletions apps/web/src/chat/fileTargets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from "bun:test";
import { workspaceFileTarget } from "./fileTargets";

describe("workspaceFileTarget", () => {
it("canonicalizes only paths contained by the active worktree", () => {
for (const [path, root, expected] of [
["module-a/../README.md", "/repo", "README.md"],
["/repo/module-a/SPEC.md", "/repo", "module-a/SPEC.md"],
["C:\\repo\\module-a\\SPEC.md", "C:\\repo", "module-a/SPEC.md"],
["/repo-other/SPEC.md", "/repo", null],
["../outside.md", "/repo", null],
["https://example.com/a.md", "/repo", null],
["file:///repo/a.md", "/repo", null],
["", "/repo", null],
] as const) {
expect(workspaceFileTarget(path, root)).toBe(expected);
}
});
});
26 changes: 26 additions & 0 deletions apps/web/src/chat/fileTargets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { isAbsolutePath, projectRelativePath } from "@/lib";

const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
const WINDOWS_ABSOLUTE_PATH = /^[A-Za-z]:[\\/]/;

export function hasUriScheme(value: string): boolean {
return URI_SCHEME.test(value);
}

export function isWindowsAbsolutePath(value: string): boolean {
return WINDOWS_ABSOLUTE_PATH.test(value);
}

export function workspaceFileTarget(
path: string,
workspaceRoot?: string | undefined,
): string | null {
const candidate = path.trim();
if (!candidate) return null;
if (!isAbsolutePath(candidate) && hasUriScheme(candidate)) return null;
const relative = projectRelativePath(candidate, workspaceRoot);
Comment thread
rsolmano marked this conversation as resolved.
if (!relative || isAbsolutePath(relative) || relative === ".." || relative.startsWith("../")) {
return null;
}
return relative;
}
8 changes: 5 additions & 3 deletions apps/web/src/chat/tools/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,16 +200,18 @@ registration runs once when the chat module mounts. Unregistered tools fall back
also registered for `get_subagent_result`, routine), the `SubagentCompletionCard` turn card, and the
pure `runDetails` readers/formatters; own child spec ([subagent/SPEC.md](subagent/SPEC.md)).
- **Shared pieces** — `CodeBlock` (shiki), `Collapsible` ("Show all N lines" fold for long output),
`ToolFileLink` + exact-reference linked text, pure `toolHelpers` (arg readers, `resultText`,
`languageFromPath`) + `lib`'s `projectRelativePath`. `resultText` delegates canonical result parsing to
`ToolFileLink` + exact-reference linked text backed by the parent chat module's shared
`workspaceFileTarget`, pure `toolHelpers` (arg readers, `resultText`, `languageFromPath`) + `lib`'s
`projectRelativePath`. `resultText` delegates canonical result parsing to
the parent chat primitive, so text extraction and the common image layer cannot disagree about what
constitutes a valid content block.

## Boundary

- **Public surface:** the side-effect `register` import + the shared `CodeBlock`/`Collapsible`/
`toolHelpers` for sibling renderers + `visualize/MermaidView` for the parent `Markdown` primitive. No barrel (chat pulls shiki — per-file imports, as in the parent).
- **Allowed deps:** parent chat primitives (`toolRegistry`, `Markdown`, `ChatActions`, `askState`);
- **Allowed deps:** parent chat primitives (`toolRegistry`, `Markdown`, `ChatActions`, `askState`,
`fileTargets`);
`contracts` (type-only + the `ASK_USER_ANSWERS_CUSTOM_TYPE` constant); `components/ui`; `lib`;
`@remixicon/react`; `mermaid` (**lazy, `visualize/` only**).
- **Forbidden:** value-importing any `pi` package; `store`/`transport` (renderers stay presentational —
Expand Down
18 changes: 3 additions & 15 deletions apps/web/src/chat/tools/ToolFileLink.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,5 @@
import { cn, isAbsolutePath, projectRelativePath } from "@/lib";

const URI_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;

export function toolFileTarget(path: string, workspaceRoot?: string | undefined): string | null {
const candidate = path.trim();
if (!candidate) return null;
if (!isAbsolutePath(candidate) && URI_SCHEME.test(candidate)) return null;
const relative = projectRelativePath(candidate, workspaceRoot);
if (!relative || isAbsolutePath(relative) || relative === ".." || relative.startsWith("../")) {
return null;
}
return relative;
}
import { hasUriScheme, workspaceFileTarget } from "../fileTargets";

export function ToolFileLink({
path,
Expand All @@ -31,10 +19,10 @@ export function ToolFileLink({
const candidate = path.trim();
const displayPath =
label ??
(candidate && !isAbsolutePath(candidate) && URI_SCHEME.test(candidate)
(candidate && !isAbsolutePath(candidate) && hasUriScheme(candidate)
? candidate
: projectRelativePath(candidate, workspaceRoot));
const target = disabled ? null : toolFileTarget(candidate, workspaceRoot);
const target = disabled ? null : workspaceFileTarget(candidate, workspaceRoot);
if (!target || !onOpenFile) {
return (
<span
Expand Down
16 changes: 0 additions & 16 deletions apps/web/src/chat/tools/ToolFileLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { renderToStaticMarkup } from "react-dom/server";
import type { ToolRenderProps } from "../toolRegistry";
import { EditCard } from "./EditCard";
import { ReadCard } from "./ReadCard";
import { toolFileTarget } from "./ToolFileLink";
import { WriteCard } from "./WriteCard";

const result = { content: [{ type: "text", text: "ok" }] };
Expand Down Expand Up @@ -36,21 +35,6 @@ function markup(
}

describe("structured tool file links", () => {
it("canonicalizes only paths contained by the active worktree", () => {
for (const [path, root, expected] of [
["module-a/../README.md", "/repo", "README.md"],
["/repo/module-a/SPEC.md", "/repo", "module-a/SPEC.md"],
["C:\\repo\\module-a\\SPEC.md", "C:\\repo", "module-a/SPEC.md"],
["/repo-other/SPEC.md", "/repo", null],
["../outside.md", "/repo", null],
["https://example.com/a.md", "/repo", null],
["file:///repo/a.md", "/repo", null],
["", "/repo", null],
] as const) {
expect(toolFileTarget(path, root)).toBe(expected);
}
});

it("opens relative and in-worktree absolute read paths under one canonical relative label", () => {
const relative = markup(ReadCard, "read", "module-a/../README.md");
const absolute = markup(ReadCard, "read", "/repo/module-a/SPEC.md");
Expand Down
Loading
Loading