put the open plan in the url, and stop /api/plans reading outside its folder

/plans/:name, no redirect guard: the bare route is 'no plan open', which is a
real state, so the auto-select-first effect is deleted rather than turned into
a Navigate. The picker stays a native select — chrome for one document, not a
master list — but it navigates instead of setting state.

Reading the server route for this turned up a path traversal: hono
percent-decodes params, so GET /api/plans/..%2F..%2Fsecret reached
join(plansDir, '../../secret.md'). basename() the param.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 12:41:55 +00:00
co-authored by Claude Opus 5
parent 32aa1e7cc3
commit 1dc0eddde0
4 changed files with 55 additions and 40 deletions
+5 -5
View File
@@ -37,14 +37,14 @@ That fix is the template for the HIGH items below. **Caveat:** the fix only did
**Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/email/:emailId`,
`/browser/:tabId`, `/tasks/:dirName`, `/skills/:dirName`, `/processes/:dirName`, `/task-logs/:id`,
`/activity/:id`. (`/projects/:id`
`/activity/:id`, `/plans/:name`. (`/projects/:id`
was in this list; the Projects feature was removed end to end on 2026-07-30, so the route is gone with it. H3
and navigate-site 22 below still name it — they are the record of work that happened, not of code that exists.)
**Section routes** (a param that names a view rather than an entity): `/settings/:page/:section`,
`/soulseek/:section`, `/headscale/:section`, `/wallet/:section`, `/system-monitor/:scope`.
**Query-param screens:** `/music?path=`, `/files?path=`, `/code-editor?file=`.
**Genuinely flat, still:** `/` `/plans` `/terminal` `/desktop` `/qr-transfer`. Of these only `/plans` selects
something (M9); the other four have nothing addressable to put in an address.
**Genuinely flat, and correctly so:** `/` `/terminal` `/desktop` `/qr-transfer` — none of them selects
anything, so there is nothing to put in an address.
The five settings pages are route **pairs** now, not flat screens — `/settings/{profile,ai,system,integrations,user-management}`
plus a `:section` each (M6). There has never been a `/settings/apps`; that entry was wrong when this list
was written.
@@ -85,7 +85,7 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
| ~~M6~~ | `Settings/SettingsPanel.tsx` | a settings sub-section | `/settings/:page/:section` | **Done.** `<NavLink>` + `useParams`, five `*_SELECTED` globals gone, one `SettingsRoute` guard per page. The "one change covers all settings pages" claim was *almost* right: Integrations builds its own sidebar and did not go through `createSettingsPanelComponents`, and it also held the Enterprise/Personal tab in a second global — derived from the section key now, which is what fixes deep-linking a Personal section. |
| ~~M7~~ | ~~`workspaces/components/Combobox.tsx:53`~~ | caller-supplied route | — | **Deleted, not fixed.** "Every caller inherits the opaque click" was the reason this ranked MEDIUM, and it is wrong: `Combobox` has **no callers**. Nothing has imported it since the initial commit, there is no barrel export, and nothing anywhere sets `href` on a `SelectOption` — so the navigate, the separator that only showed for `href` options, and the `href` field on both declarations of the type were all unreachable. Writing anchor semantics into a component that is never rendered is building, not fixing. Its `Command` primitives stay; `AIHarnessesSection` uses them. |
| ~~M8~~ | `Layout/Header/UserMenu.tsx` | — | — | **Done.** Removed rather than routed: nothing had ever been built behind `/settings/resources`, so the item was a bounce to `/` dressed as navigation. Its `header.userMenu.resources` locale keys went with it. |
| M9 | `Screens/Dashboard/Plans/index.tsx:36` | a plan document | `/plans/:name` | native `<select>` → local state; a plan is a real addressable doc. Have the select `navigate()` or use a link list. |
| ~~M9~~ | `Screens/Dashboard/Plans/index.tsx` | a plan document | `/plans/:name` | **Done.** Route pair, no `Navigate` guard — the bare route means "no plan open", which is a real state, so the auto-select-first effect was deleted rather than turned into a redirect. The `<select>` navigates instead of setting state; it stays a `<select>` on purpose (chrome for one document, not a master list) and therefore genuinely has no cmd-click — a native `<option>` cannot be an anchor. A name that no longer exists gets the empty pane, not a rewritten URL. Reading the server route for this also turned up a **path traversal**: hono percent-decodes route params, so `GET /api/plans/..%2F..%2Fsecret` reached `join(plansDir, '../../secret.md')`. Now `basename()`d. |
| ~~M10~~ | `SystemMonitor/ScopeList.tsx` | monitor scope (btop/pm2/docker) | `/system-monitor/:scope` | **Done.** Route pair + `Navigate` guard; the scope buttons are `NavLink`s and `useMonitorScope` reads `useParams` instead of the channel. The Dock's hand-rolled `isActive` is a `startsWith`, so its highlight survives the redirect. |
**Music** (M-music) and **Soulseek** (M-slsk) are whole-workspace channel apps — pulled out below because each
@@ -225,7 +225,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
- [x] **Music**`/music?path=<rel>`; `music:cwd` deleted; every drill-in (including the dock's now-playing tile, navigate-site 13) is a `<Link>`. `music:favorites` and `music:resync` stay — a view toggle and a refresh signal. **Needs runtime test.**
- [x] **Soulseek**`/soulseek/:section` with the peer in `?user=` and the search already in `?search=`; the two selection channels are deleted. Rooms and conversations are still `useState`. **Needs runtime test.**
- [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.**
- [ ] **M9** Plans → `/plans/:name` (`Plans/index.tsx:36`).
- [x] **M9** Plans → `/plans/:name`; the auto-select-first effect is gone (the bare route is a real state: no plan open), and the `<select>` navigates instead of setting state. It stays a `<select>` — a native `<option>` cannot be an anchor, so this one has no cmd-click and the doc should not pretend otherwise; it is chrome for a single document, not a master list. Reading the route also turned up a path traversal in `GET /api/plans/:name` (hono percent-decodes params, so `..%2F..%2Fx` walked out of `plansDir`) — fixed with `basename()`. **Needs runtime test.**
### Phase 4 — Polish + borderline decisions
- [ ] Dock + Header + mobile sheet → react-router `<NavLink>` for active state; drop hand-rolled `isActive` (`Dock.tsx`, `Header.tsx`).
+1
View File
@@ -54,6 +54,7 @@ export function App() {
<Route path="/chat/g/*" element={<Dashboard.SessionListPage />} />
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
<Route path="/plans" element={<Dashboard.Plans />} />
<Route path="/plans/:name" element={<Dashboard.Plans />} />
<Route path="/files" element={<Dashboard.FilesScreen />} />
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
@@ -1,43 +1,48 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
/**
* A plan is a markdown document on disk, so it gets an address: `/plans/:name`. No redirect guard — the
* bare route is "no plan open" and a name that no longer exists gets the empty pane, not a rewritten URL.
*
* The picker stays a native `<select>` rather than becoming a link list. It is chrome for one document,
* not a master list, and a `<select>` is the right control for that on a phone; it navigates instead of
* setting state, which is what M4 was actually about.
*/
export const Plans = () => {
const client = useClient();
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
const navigate = useNavigate();
const selected = useParams<{ name: string }>().name ?? null;
const { data: plans = [] } = useQuery<string[]>({
queryKey: ['plans'],
queryFn: () => client.get<string[]>('/plans'),
});
useEffect(() => {
if (plans.length > 0 && !selectedPlan) {
setSelectedPlan(plans[0]!);
}
}, [plans, selectedPlan]);
const { data: content = '' } = useQuery<string>({
queryKey: ['plans', selectedPlan],
queryFn: () => client.getText(`/plans/${selectedPlan}`),
enabled: !!selectedPlan,
queryKey: ['plans', selected],
queryFn: () => client.getText(`/plans/${encodeURIComponent(selected!)}`),
enabled: !!selected,
});
return (
<div className="flex flex-col h-full p-4">
<Card className="flex-1 overflow-hidden">
{/* Header with plan selector */}
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-duck-dark/10 bg-background/60">
<span className="text-sm font-medium text-duck-dark/70">Plans</span>
{plans.length > 1 && (
{plans.length > 0 && (
<select
value={selectedPlan ?? ''}
onChange={(ev) => setSelectedPlan(ev.target.value)}
value={selected ?? ''}
onChange={(ev) => navigate(`/plans/${encodeURIComponent(ev.target.value)}`)}
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-background/80 text-duck-dark"
>
{/* Only while nothing is chosen: it disappears once you pick, so it can never be picked back. */}
{!selected && <option value="">Select a plan</option>}
{plans.map((p) => (
<option key={p} value={p}>
{p}
@@ -47,13 +52,18 @@ export const Plans = () => {
)}
</div>
{/* Markdown content */}
<div className="overflow-y-auto h-full p-6">
{selected ? (
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:text-duck-dark prose-a:text-duck-teal prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none prose-td:text-sm prose-th:text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{content}
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-duck-dark/50">
{plans.length === 0 ? 'No plans yet.' : 'Pick a plan to read it.'}
</p>
)}
</div>
</Card>
</div>
+6 -2
View File
@@ -1,6 +1,6 @@
import { createRouter } from '../../create-router';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { basename, join } from 'node:path';
const plansDir = join(process.cwd(), 'plans');
@@ -17,7 +17,11 @@ plansRouter.get('/', async (ctx) => {
});
plansRouter.get('/:name', async (ctx) => {
const name = ctx.req.param('name');
// A single path segment is not a single *name*: hono percent-decodes params, so `..%2F..%2Fsecret`
// arrives here as `../../secret` and `join` would happily walk out of plansDir. Verified against hono
// directly. Auth limits the blast radius to the owner's own token, and the `.md` suffix limits it to
// markdown, but "read any .md on the disk" is not what this endpoint is for.
const name = basename(ctx.req.param('name'));
const filePath = join(plansDir, `${name}.md`);
const file = Bun.file(filePath);
if (!(await file.exists())) return ctx.text('Not found', 404);