the file browser can actually read a member's home, and plans is gone
"This folder is empty" was a lie. The five seeded directories were sitting there and the platform's readdir raised EACCES: a member's home is 700 and owned by them, which is correct for a shell and locks out the file browser, which runs inside the platform process. /ls caught the error and returned an empty listing, so a refusal looked exactly like data. Two doors, two boundaries, and that is the point rather than a compromise. The terminal and the agent RUN AS the member and the kernel is the boundary there. The file browser acts on the member's behalf from inside the platform, which already applies its own containment and is the owner's process on the owner's machine — it can read anything via sudo regardless. Giving it access describes who is doing the work. Done with named POSIX ACLs, because it has to hold in BOTH directions: a file the platform writes must be editable by the member and vice versa. Mode bits cannot say that — whichever party is neither owner nor group lands in "other", and widening "other" opens the home to every account on the box. A shared group fails the same way, since both parties would have to be in it and that puts every member in a group that can read every other member's home. Two named entries plus `d:` defaults grant exactly two users and are inherited by whatever either side creates, whatever their umask. Verified: platform lists the home, member edits a platform-written file, platform edits a member-written file, and a SECOND member is refused on both ls and cat. /ls now distinguishes EACCES from a missing directory. An empty result is data and must never be how a refusal looks. acl joins the core packages in setup.sh — the alternative is an account that provisions and then cannot list its own home. Also: the file browser's own useTasks/useAgents fired /tasks, /agents and both category endpoints on every render, which is where the last four 403s came from — they are the context menu's Run Task and agent submenus, execution-only. Gated. And plans is deleted: router, screen, routes, dock tile, hook, page title and its capability. It read markdown from <repo>/plans, which does not exist. Fresh-install Permissions is now Files alone, with Terminal to come. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,8 +53,6 @@ export function App() {
|
||||
<Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} />
|
||||
<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 />} />
|
||||
|
||||
@@ -169,7 +169,6 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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 navigate = useNavigate();
|
||||
const selected = useParams<{ name: string }>().name ?? null;
|
||||
|
||||
const { data: plans = [] } = useQuery<string[]>({
|
||||
queryKey: ['plans'],
|
||||
queryFn: () => client.get<string[]>('/plans'),
|
||||
});
|
||||
|
||||
const { data: content = '' } = useQuery<string>({
|
||||
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">
|
||||
<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 > 0 && (
|
||||
<select
|
||||
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}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,6 @@ export * from './AppStore';
|
||||
export * from './Layout';
|
||||
export * from './Home';
|
||||
export * from './PasskeyGate';
|
||||
export * from './Plans';
|
||||
export * from './Processes';
|
||||
export * from './CapabilityPage';
|
||||
export * from './Settings';
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { usePlans } from 'state/usePlans';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useModels } from 'state/useModels';
|
||||
import { useAccessPolicy } from 'state/useAccessPolicy';
|
||||
import { useColorModeSync } from './useThemeSync';
|
||||
|
||||
// Caches the shell wants warm before anything asks for them. Called once from App.tsx for its effects —
|
||||
// the return value has never been read.
|
||||
export const useInitialData = () => {
|
||||
const { plans } = usePlans();
|
||||
const { settings } = useSettings();
|
||||
useModels();
|
||||
useAccessPolicy();
|
||||
useColorModeSync();
|
||||
|
||||
return { plans, settings };
|
||||
return { settings };
|
||||
};
|
||||
|
||||
@@ -43,7 +43,6 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/terminal'), title: 'Terminal' },
|
||||
{ match: (p) => p.startsWith('/browser'), title: 'Browser' },
|
||||
{ match: (p) => p.startsWith('/desktop'), title: 'Desktop' },
|
||||
{ match: (p) => p.startsWith('/plans'), title: 'Plans' },
|
||||
];
|
||||
|
||||
export function titleForPath(pathname: string): string {
|
||||
|
||||
@@ -195,7 +195,19 @@ router.get('/ls', async (ctx) => {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(absPath);
|
||||
} catch {
|
||||
} catch (ex) {
|
||||
// A missing directory resets the browser to the root, which is the right answer for a stale path.
|
||||
//
|
||||
// A PERMISSION failure is not that, and conflating them cost an afternoon: a member's home is 700 and
|
||||
// theirs, so before the ACL grant in os-user.ts the platform's readdir raised EACCES here and this
|
||||
// returned an empty listing — the UI said "This folder is empty" over five directories that existed.
|
||||
// An empty result is data; it should never be how a refusal looks.
|
||||
if ((ex as { code?: string }).code === 'EACCES' || (ex as { code?: string }).code === 'EPERM') {
|
||||
throw errors.FORBIDDEN(
|
||||
`Officer cannot read ${relPath || 'this folder'}. If this is a member's home, its access control ` +
|
||||
`lists are missing — reprovision the Linux account from Settings → User management.`,
|
||||
);
|
||||
}
|
||||
return ctx.json({ path: '/', entries: [], reset: true });
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
|
||||
const plansDir = join(process.cwd(), 'plans');
|
||||
|
||||
export const plansRouter = createRouter();
|
||||
|
||||
plansRouter.get('/', async (ctx) => {
|
||||
try {
|
||||
const files = await readdir(plansDir);
|
||||
const plans = files.filter((f) => f.endsWith('.md')).map((f) => f.replace('.md', ''));
|
||||
return ctx.json(plans);
|
||||
} catch {
|
||||
return ctx.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
plansRouter.get('/:name', async (ctx) => {
|
||||
// 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);
|
||||
const text = await file.text();
|
||||
return ctx.text(text);
|
||||
});
|
||||
@@ -253,14 +253,6 @@ export const CAPABILITIES: Capability[] = [
|
||||
routes: ['/dashboards'],
|
||||
personal: ['/'],
|
||||
},
|
||||
{
|
||||
key: 'plans',
|
||||
label: 'Plans',
|
||||
description: 'Plan documents',
|
||||
kind: 'app',
|
||||
api: ['/plans'],
|
||||
routes: ['/plans'],
|
||||
},
|
||||
|
||||
// ── execution: never grantable ──────────────────────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ import { landingPageDataRouter } from './api/landing-page-data/landing-page-data
|
||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||
import { usersRouter } from './api/users/users-router';
|
||||
import { apiKeysRouter } from './api/api-keys/router';
|
||||
import { plansRouter } from './api/plans/plans';
|
||||
import { skillsRouter } from './api/skills/skills';
|
||||
import { tasksRouter } from './api/tasks/tasks';
|
||||
import { agentsRouter } from './api/agents/agents';
|
||||
@@ -168,7 +167,6 @@ protectedRouter.use(userMiddleware);
|
||||
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
|
||||
['/server-settings', serverSettingsRouter],
|
||||
['/users', usersRouter],
|
||||
['/plans', plansRouter],
|
||||
['/skills', skillsRouter],
|
||||
['/tasks', tasksRouter],
|
||||
['/agents', agentsRouter],
|
||||
|
||||
@@ -319,6 +319,46 @@ export async function confineUserTree(params: {
|
||||
const close = await run(['sudo', '-n', 'chmod', '700', home]);
|
||||
if (!close.ok) return { ok: false, error: `chmod of ${home} failed: ${close.out}` };
|
||||
|
||||
// ── And then let the PLATFORM in, by ACL ──
|
||||
//
|
||||
// A 700 home owned by the member locks out the service user, which is correct for a shell and fatal for
|
||||
// the file browser: it runs inside the platform process, so `readdir` returned EACCES and `/ls` reported
|
||||
// "This folder is empty" over five directories that were sitting right there. Observed 2026-08-11.
|
||||
//
|
||||
// These are two different doors and they need different boundaries. The terminal and the agent RUN AS the
|
||||
// member, and there the kernel is the boundary. The file browser acts on the member's behalf from inside
|
||||
// the platform, which already applies its own containment (`resolveUserPath`) and which is the owner's
|
||||
// process on the owner's machine — it can read anything via sudo regardless. Giving it access is not a
|
||||
// hole, it is the honest description of who is doing the work.
|
||||
//
|
||||
// Why ACLs and not mode bits or a group. It has to work in BOTH directions: a file the platform writes
|
||||
// must be editable by the member, and a file the member writes must be editable by the platform. Mode
|
||||
// bits cannot express that — whichever of the two is neither owner nor group ends up as "other", and
|
||||
// widening "other" would open the home to every account on the box. A shared group fails the same way
|
||||
// once you notice both parties would have to be in it, which would put every member in a group that can
|
||||
// read every other member's home. Named ACL entries grant exactly two users, and the `d:` defaults are
|
||||
// inherited by everything created afterwards, by either party, whatever their umask.
|
||||
const serviceUid = process.getuid?.();
|
||||
if (serviceUid !== undefined) {
|
||||
const entries = [
|
||||
`u:${serviceUid}:rwx`,
|
||||
`u:${params.uid}:rwx`,
|
||||
`d:u:${serviceUid}:rwx`,
|
||||
`d:u:${params.uid}:rwx`,
|
||||
].flatMap((entry) => ['-m', entry]);
|
||||
// After the chmod, never before: chmod recomputes the ACL mask and would clamp entries set earlier.
|
||||
const acl = await run(['sudo', '-n', 'setfacl', '-R', ...entries, home]);
|
||||
if (!acl.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`could not set access control lists on ${home}: ${acl.out}. ` +
|
||||
`The file browser cannot read a member's home without them. ` +
|
||||
`Install the acl package (apt install acl) and retry.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (ex) {
|
||||
return { ok: false, error: ex instanceof Error ? ex.message : String(ex) };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { type TriggerConfig, groupByCategory, matchesTrigger } from './useTasks';
|
||||
|
||||
export type AgentSummary = {
|
||||
@@ -17,15 +18,21 @@ export type AgentGroup = { category: string; agents: AgentSummary[] };
|
||||
// wrong thing about what happens when you click.
|
||||
export const useAgents = () => {
|
||||
const client = useClient();
|
||||
// Agents are the `items` capability — skills, tools and agents on the owner's disk — and running one
|
||||
// starts a chat session, which is `chat`. Both are execution-only, so a member gets no agent submenu.
|
||||
const { can } = useCapabilities();
|
||||
const allowed = can('items');
|
||||
|
||||
const { data: agents = [] } = useQuery<AgentSummary[]>({
|
||||
queryKey: ['agents'],
|
||||
enabled: allowed,
|
||||
queryFn: () => client.get('/agents'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: categoryOrder = [] } = useQuery<string[]>({
|
||||
queryKey: ['agent-categories'],
|
||||
enabled: allowed,
|
||||
queryFn: () => client.get('/agents/categories'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
|
||||
export type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
|
||||
|
||||
@@ -70,15 +71,22 @@ export const matchesTrigger = (
|
||||
|
||||
export const useTasks = () => {
|
||||
const client = useClient();
|
||||
// `tasks` is `kind: 'execution'`: a task run executes a script as the server owner. A member browsing
|
||||
// their own files has a file browser, not a task runner — so the context menu simply has no Run Task
|
||||
// submenu, and these two requests are not made. Without the guard they 403'd on every Files render.
|
||||
const { can } = useCapabilities();
|
||||
const allowed = can('tasks');
|
||||
|
||||
const { data: tasks = [] } = useQuery<TaskSummary[]>({
|
||||
queryKey: ['tasks'],
|
||||
enabled: allowed,
|
||||
queryFn: () => client.get('/tasks'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: categoryOrder = [] } = useQuery<string[]>({
|
||||
queryKey: ['task-categories'],
|
||||
enabled: allowed,
|
||||
queryFn: () => client.get('/tasks/categories'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ export { useAccessPolicy } from './useAccessPolicy';
|
||||
export { useClaudeSessions, useChatPwds } from './useClaudeSessions';
|
||||
export type { ClaudeSessionSummary, ClaudePwd } from './useClaudeSessions';
|
||||
export { useRecentModels } from './useRecentModels';
|
||||
export { usePlans } from './usePlans';
|
||||
export { useLandingPage } from './useLandingPage';
|
||||
export { useServerSettings } from './useServerSettings';
|
||||
export { useServerEnvironment } from './useServerEnvironment';
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const usePlans = () => {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { can } = useCapabilities();
|
||||
|
||||
const { data: plans = [] } = useQuery<string[]>({
|
||||
queryKey: ['PLANS'],
|
||||
enabled: isAuthenticated && can('plans'),
|
||||
queryFn: () => client.get<string[]>('/plans'),
|
||||
});
|
||||
|
||||
const getPlan = (name: string) => client.getText(`/plans/${name}`);
|
||||
|
||||
return { plans, getPlan };
|
||||
};
|
||||
Reference in New Issue
Block a user