let a member read the two directories above their own home

`bun run` from anywhere inside a member's home died with

    error loading current directory
    error: An internal error occurred (CouldntReadCurrentDirectory)

before it looked at package.json, bun.lock or .git — all of which were present.

it is not walking up looking for a workspace root. it primes its resolver cache
by walking DOWN from / and opening every component of the cwd for READING:

    openat("/home/pastilhas/officerdev/")              = 6
    openat(".../officerdev/data/")                     = -1 EACCES
    openat(".../officerdev/data/<email>/")             = -1 EACCES

those two are 711 — traversable, not listable — which is enough to cd into a
home and not enough for a program that reads its ancestors. `getcwd` succeeds;
the ancestor read is what fails. `O_PATH` would need only `x`, so this is
arguably bun's bug, but it presents as a member's project being mysteriously
unbuildable and nothing here can fix it from the other side.

so DATA_PATH and the account dir now carry a named ACL entry per member. that
gives up the property the old comment named — a member can now `ls` DATA_PATH
and learn the other accounts' email addresses — and keeps everything that
matters: every home is still 700 and owned by its member, every platform
sibling still 700 and owned by the service user. verified as green: the account
list is visible, and email_accounts, another home, the repo .env, the owner's
ssh key, .pgpass and ~/.claude/.credentials.json are all still denied.

the mask is set explicitly to rx alongside the entry. chmod recomputes the mask
from the group bits, which for 711 is --x, so without that the next member's
provisioning would silently clamp every earlier member back to traverse-only.

applied by hand to the one existing account; provisioning covers new ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 20:00:29 +00:00
co-authored by Claude Opus 5
parent 56bb383c6d
commit 4c3682dae6
+37 -2
View File
@@ -297,11 +297,46 @@ export async function confineUserTree(params: {
try {
if (!existsSync(home)) await mkdir(home, { recursive: true });
// Traversable, not listable. Applied to DATA_PATH itself too: without it a member can read the
// directory and learn every other member's email address.
// Traversable, not listable — for everyone EXCEPT the members themselves, who are named below.
//
// ── Why "not listable" could not be kept ──
//
// 711 says: pass through, do not read. That is enough to `cd` into a home and not enough for a program
// that READS its ancestors, and at least one in daily use does. `bun run` primes its module-resolution
// cache by walking DOWN from `/` and opening every component of the cwd with `O_RDONLY|O_DIRECTORY`:
//
// openat("/home/pastilhas/officerdev/") = 6
// openat("/home/pastilhas/officerdev/data/") = -1 EACCES
// openat("/home/pastilhas/officerdev/data/<email>/") = -1 EACCES
//
// and dies with `CouldntReadCurrentDirectory` before it ever looks for `package.json`. `getcwd` succeeds;
// it is the read of the ancestors that fails. Traversal alone would do — `O_PATH` needs only `x` — so
// this is arguably Bun's bug, but it is not one this repository can fix, and it presents as a project
// being mysteriously unbuildable from a member's shell.
//
// The cost is stated plainly: a member can now `ls` DATA_PATH and learn the other accounts' email
// addresses. Their CONTENTS stay shut — every `home` is 700 and owned by its member, and every sibling
// is 700 and owned by the service user. What is given up is the account list, not any account's data.
//
// Named ACL entries rather than `chmod 755`, so this reaches members and not every account on the box.
await chmod(DATA_PATH, 0o711);
await chmod(accountDir, 0o711);
// After the chmods, never before — chmod recomputes the ACL mask from the group bits, which for 711 is
// `--x`, and that would clamp every member entry (including ones added by earlier provisions) down to
// traverse-only. Setting `m::rx` explicitly restores them all, so provisioning a second member does not
// silently re-break the first.
const uidEntry = `u:${params.uid}:rx`;
const openUp = await run(['sudo', '-n', 'setfacl', '-m', `${uidEntry},m::rx`, DATA_PATH, accountDir]);
if (!openUp.ok) {
return {
ok: false,
error:
`could not grant ${params.email} read access to ${DATA_PATH}: ${openUp.out}. ` +
`Without it their own tooling cannot resolve paths inside their home.`,
};
}
// Every sibling of `home` is the platform's. 700 means traversal alone does not open them.
const entries = await readdir(accountDir, { withFileTypes: true });
for (const entry of entries) {