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
@@ -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,
});
-1
View File
@@ -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';
-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 };
};