feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+40
-8
@@ -98,6 +98,38 @@ function isSecret(rel) {
|
||||
return norm === SECRET_FILE || path.posix.basename(norm) === SECRET_BASENAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* S3: whether `abs` is a dot entry at the TOP of a member's home — `.claude`,
|
||||
* `.codex`, `.config/gh`, `.ssh`, `.git-credentials`, `.agenthub/handoff`…:
|
||||
* where that member's logins and pane tokens live. A workspace is a folder
|
||||
* under the home, so this never hides project files; it closes a workspace
|
||||
* rooted AT the home (made before that was refused, or a symlink) to anyone
|
||||
* reading it, analysts included. Not by basename: `auth`/`token` patterns
|
||||
* would hide `AuthGate.tsx` in every member workspace.
|
||||
*/
|
||||
function inMemberDotDir(abs) {
|
||||
const runAs = require("./run-as");
|
||||
const acct = require("./linux-accounts").s3Enabled() ? runAs.ownerForRead(abs) : null;
|
||||
if (!acct) return false;
|
||||
const rel = path.relative(acct.home, abs);
|
||||
return Boolean(rel) && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* The real path of a workspace folder for a filesystem read or write (S3:
|
||||
* refused, EFOREIGN, when it resolves into another account's files). The
|
||||
* account a read or write runs as comes from the PATH (run-as.js), so a
|
||||
* member who swaps their workspace folder for a link to another member's
|
||||
* folder, the host's home or /etc must not have the dashboard follow it.
|
||||
*/
|
||||
async function laneRoot(dir) {
|
||||
const root = await fs.promises.realpath(dir);
|
||||
if (!require("./run-as").sameOwner(path.resolve(dir), root)) {
|
||||
throw badRequest("EFOREIGN", "the workspace folder resolves outside its owner's files");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `file` in the lane is a secret by what it RESOLVES to, not by how
|
||||
* it is spelled: a symlinked `.claude` (→ `conf/`) makes the key file
|
||||
@@ -107,7 +139,7 @@ function isSecret(rel) {
|
||||
* path that does not exist on disk is judged by its name alone.
|
||||
*/
|
||||
async function isSecretOnDisk(dir, file) {
|
||||
if (isSecret(file)) return true;
|
||||
if (isSecret(file) || inMemberDotDir(path.resolve(dir, file))) return true;
|
||||
let root;
|
||||
let real;
|
||||
try {
|
||||
@@ -116,7 +148,7 @@ async function isSecretOnDisk(dir, file) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (isSecret(path.relative(root, real))) return true;
|
||||
if (isSecret(path.relative(root, real)) || inMemberDotDir(real)) return true;
|
||||
try {
|
||||
return real === (await fs.promises.realpath(path.join(root, SECRET_FILE)));
|
||||
} catch {
|
||||
@@ -162,7 +194,7 @@ async function resolveInLane(dir, file) {
|
||||
if (escapes(path.resolve(dir), path.resolve(dir, file))) {
|
||||
throw badRequest("EBADPATH", "path escapes the workspace directory");
|
||||
}
|
||||
const root = await fs.promises.realpath(dir);
|
||||
const root = await laneRoot(dir);
|
||||
let real;
|
||||
try {
|
||||
real = await fs.promises.realpath(path.resolve(root, file));
|
||||
@@ -406,7 +438,7 @@ async function untrackedFacts(dir, file) {
|
||||
const abs = await resolveInLane(dir, file);
|
||||
if ((await fs.promises.stat(abs)).size > MAX_UNTRACKED_BYTES)
|
||||
return { lines: null, binary: true };
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
const buf = await require("./run-as").readFileSafeAsync(abs);
|
||||
if (looksBinary(buf)) return { lines: null, binary: true };
|
||||
const text = buf.toString("utf8");
|
||||
if (!text) return { lines: 0, binary: false };
|
||||
@@ -502,7 +534,7 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
|
||||
async function synthesizeAddedPatch(dir, file) {
|
||||
let buf;
|
||||
try {
|
||||
buf = await fs.promises.readFile(await resolveInLane(dir, file));
|
||||
buf = await require("./run-as").readFileSafeAsync(await resolveInLane(dir, file));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -763,7 +795,7 @@ async function readFileAt(dir, requested, rev = "work") {
|
||||
// Only cap+1 bytes: a multi-gigabyte log in the lane must not be read
|
||||
// whole to answer with its first megabyte. The extra byte is what
|
||||
// tells the cap below the file was longer.
|
||||
const fh = await fs.promises.open(abs, "r");
|
||||
const fh = await require("./run-as").openSafeAsync(abs);
|
||||
try {
|
||||
const size = Math.min((await fh.stat()).size, MAX_FILE_VIEW_BYTES + 1);
|
||||
buf = Buffer.alloc(size);
|
||||
@@ -848,7 +880,7 @@ async function readImageAt(dir, requested, rev = "work") {
|
||||
}
|
||||
if (!stat.isFile()) throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
if (stat.size > MAX_IMAGE_BYTES) throw tooBig();
|
||||
return { type, buf: await fs.promises.readFile(abs) };
|
||||
return { type, buf: await require("./run-as").readFileSafeAsync(abs) };
|
||||
}
|
||||
try {
|
||||
const { stdout } = await git(dir, ["show", `${rev}:./${file}`], {
|
||||
@@ -937,7 +969,7 @@ async function addIgnored(dir, sub, dirNames, files) {
|
||||
const added = new Set();
|
||||
let dirents;
|
||||
try {
|
||||
const root = await fs.promises.realpath(dir);
|
||||
const root = await laneRoot(dir);
|
||||
const real = await fs.promises.realpath(path.join(root, sub));
|
||||
const r = path.relative(root, real);
|
||||
// A symlinked folder pointing out of the workspace lists nothing.
|
||||
|
||||
Reference in New Issue
Block a user