From 4c3682dae665f8b6f9d4fdccb8235d5ec092e8e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Al=C3=A7ada=20Padez?= Date: Fri, 14 Aug 2026 20:00:29 +0000 Subject: [PATCH] let a member read the two directories above their own home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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//") = -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 --- src/servers/os-user.ts | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index da09d2fd..8e57bc5d 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -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//") = -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) {