files, for a member, in their own home

Introduces a fifth capability kind. `files` was `execution` — never grantable,
because it meant the OWNER'S filesystem. It is now `confined`: execution-shaped, but
the kernel enforces the boundary because the account has its own Linux user, its own
home, and no permission above it.

The rule that makes `confined` mean something lives in authorize.ts, once: a confined
grant is DROPPED for an account with no osUser. So "granted but unconfined" resolves
to no access rather than to the owner's home — which is what it would otherwise
resolve to, since getOwnerHomeDir ignores the email it is handed whenever HOME_DIR is
set. One rule covers the HTTP routes, the websocket doors and the dock, instead of
each router remembering.

resolveHomeDir(userId) is the new seam and it reads the row rather than the token, for
the same reason authorize.ts re-reads role: provisioning a Linux account for an
existing member has to take effect on the next request, not in thirty days.

The file browser resolves it in middleware and puts it on ctx user, because
getRootDir is called from fifteen places in that router. Making it async would have
meant editing fifteen call sites, and the cost of missing one is serving the owner's
home to a member. Now a handler cannot run without the answer.

Two things a real run caught:

- /ls seeds Downloads/Documents into the home as the service user, which is EPERM
  against a 700 home owned by the member — it took the whole listing down. Seeding is
  now best-effort there and happens at provision time instead, as the member.
- .unique() on os_user made db:push ask whether to TRUNCATE users, which is
  unanswerable non-interactively. uniqueIndex instead, per databases/CLAUDE.md.

Verified: a member without a Linux account is refused by name; with one, resolves to
their own home and NOT to HOME_DIR; the owner still resolves to HOME_DIR; and every
.. escape is refused while an absolute path is rebased under the root.

Terminal is still execution — that is the next stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 17:34:31 +00:00
co-authored by Claude Opus 5
parent 0fb9a29e64
commit 4d513c0e13
10 changed files with 226 additions and 19 deletions
+48 -7
View File
@@ -2,7 +2,8 @@ import { createRouter } from '@@/create-router';
import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
import { getOwnerHomeDir, DATA_PATH, HOME_SEED_DIRS } from '@@/data-path';
import { resolveHomeDir } from '@@/user-home';
import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readSttConfig } from '@@/api/server-settings/stt';
@@ -18,7 +19,7 @@ async function getUserTtsVoice(userId: number): Promise<string | null> {
return null;
}
const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Videos', 'Pictures'];
const DEFAULT_HOME_DIRS = HOME_SEED_DIRS;
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
async function cleanOldCacheDirs(userDataDir: string) {
@@ -37,14 +38,45 @@ async function seedHomeDir(homeDir: string) {
export const router = createRouter();
type UserCtx = { email: string };
/**
* Resolve whose home this request may touch, once, before any handler runs.
*
* A middleware rather than a change to `getRootDir`'s signature because that function is called from
* fifteen places in this file. Making it async would have meant editing fifteen call sites, and the
* failure mode of missing one is the worst available: a handler that quietly serves the OWNER'S home to a
* member. Resolving here means a handler cannot run without the answer.
*
* The `user-data` root is untouched by this — it is already keyed on the caller's own email and holds
* platform-written data rather than anything executable.
*/
router.use(async (ctx, next) => {
const user = ctx.get('user');
const resolved = await resolveHomeDir(user.id as number);
if (!resolved.ok) {
throw resolved.needsOsAccount
? errors.FORBIDDEN(`Files are not available for this account: ${resolved.reason}.`)
: errors.FORBIDDEN(resolved.reason);
}
ctx.set('user', { ...user, homeDir: resolved.home });
return next();
});
/**
* `homeDir` is put on the context user by `confineToHome` below, so the fifteen-odd call sites of
* `getRootDir` keep working unchanged and none of them can forget to resolve it.
*/
type UserCtx = { email: string; homeDir?: string };
function getUserDataDir(email: string): string {
return join(DATA_PATH, email);
}
export function getRootDir(user: UserCtx, root?: string): string {
if (!root || root === 'home') return getOwnerHomeDir(user.email);
// `user.homeDir` is set for every request that reached a handler — the middleware refuses the request
// otherwise. The fallback exists only for the owner-shaped callers that construct a UserCtx by hand;
// it is NOT a "member without an OS account gets the owner's home" path, because such a request never
// gets this far. See user-home.ts for why that distinction is the whole point.
if (!root || root === 'home') return user.homeDir ?? getOwnerHomeDir(user.email);
if (root === 'user-data') return getUserDataDir(user.email);
throw errors.BAD_REQUEST(`Invalid root: ${root}`);
}
@@ -140,10 +172,19 @@ router.get('/ls', async (ctx) => {
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
const absPath = resolveUserPath(rootDir, relPath);
// Auto-create dir if missing (only for user home root)
// Auto-create dir if missing (only for user home root).
//
// Non-fatal since per-user Linux accounts: a member's home is 700 and owned by THEM, so the platform
// cannot write into it and every one of these calls raises EPERM. Their folders are seeded at account
// creation, as them. Letting a convenience take down `/ls` would mean the file browser failing to list a
// directory it can read perfectly well.
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
await seedHomeDir(rootDir);
await mkdir(absPath, { recursive: true });
try {
await seedHomeDir(rootDir);
await mkdir(absPath, { recursive: true });
} catch {
// Nothing to report: either it exists, or it is not ours to create. `readdir` below is the real test.
}
}
// Remove old top-level cache dirs (migrated to cache/ prefix)
+1 -1
View File
@@ -114,7 +114,7 @@ capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => {
// grant the resolver would drop on read anyway.
const known = CAPABILITY_BY_KEY.get(capability);
if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`);
if (known.kind !== 'app') {
if (known.kind !== 'app' && known.kind !== 'confined') {
throw errors.BAD_REQUEST(
known.kind === 'execution'
? `${known.label} runs as the server owner and can never be granted`