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:
2026-08-11 18:29:36 +00:00
co-authored by Claude Opus 5
parent 6b4fed68fd
commit d3bed0add9
16 changed files with 86 additions and 141 deletions
+15
View File
@@ -230,6 +230,21 @@ case $PM in
;; ;;
esac esac
# acl — setfacl/getfacl, needed by per-user Linux accounts.
#
# A member's home is 700 and owned by them, which is right for a shell and locks the platform out of the
# file browser. Named ACL entries are what let both act on the same files without opening the home to every
# account on the box; mode bits cannot express it in both directions. Core rather than a profile extra
# because the alternative is an account that provisions and then cannot list its own home.
case $PM in
apt)
if dpkg -s acl &>/dev/null 2>&1; then skip "acl"; else CORE_PKGS+=(acl); fi
;;
pacman|dnf|yum)
if has setfacl; then skip "acl"; else CORE_PKGS+=(acl); fi
;;
esac
if [ ${#CORE_PKGS[@]} -gt 0 ]; then if [ ${#CORE_PKGS[@]} -gt 0 ]; then
install_pkg "${CORE_PKGS[@]}" install_pkg "${CORE_PKGS[@]}"
ok "Installed: ${CORE_PKGS[*]}" ok "Installed: ${CORE_PKGS[*]}"
-2
View File
@@ -53,8 +53,6 @@ export function App() {
<Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} /> <Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} />
<Route path="/chat/g/*" element={<Dashboard.SessionListPage />} /> <Route path="/chat/g/*" element={<Dashboard.SessionListPage />} />
<Route path="/chat/:sessionId" 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="/files" element={<Dashboard.FilesScreen />} />
<Route path="/calendar" element={<Dashboard.CalendarScreen />} /> <Route path="/calendar" element={<Dashboard.CalendarScreen />} />
<Route path="/contacts" element={<Dashboard.ContactsScreen />} /> <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: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' }, { label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { 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: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' }, { label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' }, { 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 './Layout';
export * from './Home'; export * from './Home';
export * from './PasskeyGate'; export * from './PasskeyGate';
export * from './Plans';
export * from './Processes'; export * from './Processes';
export * from './CapabilityPage'; export * from './CapabilityPage';
export * from './Settings'; export * from './Settings';
+3 -3
View File
@@ -1,15 +1,15 @@
import { usePlans } from 'state/usePlans';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import { useModels } from 'state/useModels'; import { useModels } from 'state/useModels';
import { useAccessPolicy } from 'state/useAccessPolicy'; import { useAccessPolicy } from 'state/useAccessPolicy';
import { useColorModeSync } from './useThemeSync'; 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 = () => { export const useInitialData = () => {
const { plans } = usePlans();
const { settings } = useSettings(); const { settings } = useSettings();
useModels(); useModels();
useAccessPolicy(); useAccessPolicy();
useColorModeSync(); useColorModeSync();
return { plans, settings }; return { settings };
}; };
@@ -43,7 +43,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/terminal'), title: 'Terminal' }, { match: (p) => p.startsWith('/terminal'), title: 'Terminal' },
{ match: (p) => p.startsWith('/browser'), title: 'Browser' }, { match: (p) => p.startsWith('/browser'), title: 'Browser' },
{ match: (p) => p.startsWith('/desktop'), title: 'Desktop' }, { match: (p) => p.startsWith('/desktop'), title: 'Desktop' },
{ match: (p) => p.startsWith('/plans'), title: 'Plans' },
]; ];
export function titleForPath(pathname: string): string { export function titleForPath(pathname: string): string {
+13 -1
View File
@@ -195,7 +195,19 @@ router.get('/ls', async (ctx) => {
let names: string[]; let names: string[];
try { try {
names = await readdir(absPath); 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 }); return ctx.json({ path: '/', entries: [], reset: true });
} }
const entries = await Promise.all( const entries = await Promise.all(
-30
View File
@@ -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);
});
-8
View File
@@ -253,14 +253,6 @@ export const CAPABILITIES: Capability[] = [
routes: ['/dashboards'], routes: ['/dashboards'],
personal: ['/'], personal: ['/'],
}, },
{
key: 'plans',
label: 'Plans',
description: 'Plan documents',
kind: 'app',
api: ['/plans'],
routes: ['/plans'],
},
// ── execution: never grantable ────────────────────────────────────────────────────────────────── // ── execution: never grantable ──────────────────────────────────────────────────────────────────
{ {
-2
View File
@@ -8,7 +8,6 @@ import { landingPageDataRouter } from './api/landing-page-data/landing-page-data
import { waitlistRouter } from './api/waitlist/waitlist'; import { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router'; import { usersRouter } from './api/users/users-router';
import { apiKeysRouter } from './api/api-keys/router'; import { apiKeysRouter } from './api/api-keys/router';
import { plansRouter } from './api/plans/plans';
import { skillsRouter } from './api/skills/skills'; import { skillsRouter } from './api/skills/skills';
import { tasksRouter } from './api/tasks/tasks'; import { tasksRouter } from './api/tasks/tasks';
import { agentsRouter } from './api/agents/agents'; import { agentsRouter } from './api/agents/agents';
@@ -168,7 +167,6 @@ protectedRouter.use(userMiddleware);
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
['/server-settings', serverSettingsRouter], ['/server-settings', serverSettingsRouter],
['/users', usersRouter], ['/users', usersRouter],
['/plans', plansRouter],
['/skills', skillsRouter], ['/skills', skillsRouter],
['/tasks', tasksRouter], ['/tasks', tasksRouter],
['/agents', agentsRouter], ['/agents', agentsRouter],
+40
View File
@@ -319,6 +319,46 @@ export async function confineUserTree(params: {
const close = await run(['sudo', '-n', 'chmod', '700', home]); const close = await run(['sudo', '-n', 'chmod', '700', home]);
if (!close.ok) return { ok: false, error: `chmod of ${home} failed: ${close.out}` }; 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 }; return { ok: true };
} catch (ex) { } catch (ex) {
return { ok: false, error: ex instanceof Error ? ex.message : String(ex) }; return { ok: false, error: ex instanceof Error ? ex.message : String(ex) };
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
import { type TriggerConfig, groupByCategory, matchesTrigger } from './useTasks'; import { type TriggerConfig, groupByCategory, matchesTrigger } from './useTasks';
export type AgentSummary = { export type AgentSummary = {
@@ -17,15 +18,21 @@ export type AgentGroup = { category: string; agents: AgentSummary[] };
// wrong thing about what happens when you click. // wrong thing about what happens when you click.
export const useAgents = () => { export const useAgents = () => {
const client = useClient(); 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[]>({ const { data: agents = [] } = useQuery<AgentSummary[]>({
queryKey: ['agents'], queryKey: ['agents'],
enabled: allowed,
queryFn: () => client.get('/agents'), queryFn: () => client.get('/agents'),
staleTime: 60_000, staleTime: 60_000,
}); });
const { data: categoryOrder = [] } = useQuery<string[]>({ const { data: categoryOrder = [] } = useQuery<string[]>({
queryKey: ['agent-categories'], queryKey: ['agent-categories'],
enabled: allowed,
queryFn: () => client.get('/agents/categories'), queryFn: () => client.get('/agents/categories'),
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
export type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' }; export type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
@@ -70,15 +71,22 @@ export const matchesTrigger = (
export const useTasks = () => { export const useTasks = () => {
const client = useClient(); 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[]>({ const { data: tasks = [] } = useQuery<TaskSummary[]>({
queryKey: ['tasks'], queryKey: ['tasks'],
enabled: allowed,
queryFn: () => client.get('/tasks'), queryFn: () => client.get('/tasks'),
staleTime: 60_000, staleTime: 60_000,
}); });
const { data: categoryOrder = [] } = useQuery<string[]>({ const { data: categoryOrder = [] } = useQuery<string[]>({
queryKey: ['task-categories'], queryKey: ['task-categories'],
enabled: allowed,
queryFn: () => client.get('/tasks/categories'), queryFn: () => client.get('/tasks/categories'),
staleTime: 60_000, staleTime: 60_000,
}); });
-1
View File
@@ -8,7 +8,6 @@ export { useAccessPolicy } from './useAccessPolicy';
export { useClaudeSessions, useChatPwds } from './useClaudeSessions'; export { useClaudeSessions, useChatPwds } from './useClaudeSessions';
export type { ClaudeSessionSummary, ClaudePwd } from './useClaudeSessions'; export type { ClaudeSessionSummary, ClaudePwd } from './useClaudeSessions';
export { useRecentModels } from './useRecentModels'; export { useRecentModels } from './useRecentModels';
export { usePlans } from './usePlans';
export { useLandingPage } from './useLandingPage'; export { useLandingPage } from './useLandingPage';
export { useServerSettings } from './useServerSettings'; export { useServerSettings } from './useServerSettings';
export { useServerEnvironment } from './useServerEnvironment'; export { useServerEnvironment } from './useServerEnvironment';
-20
View File
@@ -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 };
};