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:
+74
-7
@@ -285,21 +285,36 @@ function audienceIncludes(audience, user) {
|
||||
return Boolean(audience.owners && audience.owners.has(user.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-admin's own folder, `<AGENTHUB_USERS_ROOT>/<username>`, or null (no
|
||||
* root set, or an admin). Whatever the method: an analyst reads every user's
|
||||
* ROWS, but a path named in a request is read with the dashboard's own rights
|
||||
* (CAP_DAC_READ_SEARCH under S3), so an analyst's GET is held to it too.
|
||||
*/
|
||||
function ownFolder(req) {
|
||||
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
|
||||
if (!req?.user || req.user.role === "admin" || !root) return null;
|
||||
const path = require("node:path");
|
||||
return path.join(path.resolve(root), req.user.username);
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder a member's workspace may use: anywhere outside server mode or for
|
||||
* an admin; for a member, only inside `<AGENTHUB_USERS_ROOT>/<username>` when
|
||||
* that root is set. `dir` must already be absolute and canonical. Until
|
||||
* accounts are Linux users (S3) this keeps workspaces apart, not agents: a
|
||||
* terminal can still reach any folder the host account can.
|
||||
* terminal can still reach any folder the host account can. Under S3 the
|
||||
* home ITSELF is no workspace (its dot folders hold the member's logins and
|
||||
* pane tokens, which a workspace's file views would serve) — `allowHome`
|
||||
* is for browsing to a folder, not for making one a workspace.
|
||||
*/
|
||||
function folderAllowed(req, dir) {
|
||||
if (memberId(req) == null) return true;
|
||||
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
|
||||
if (!root) return true;
|
||||
function folderAllowed(req, dir, { allowHome = false } = {}) {
|
||||
const home = ownFolder(req);
|
||||
if (!home) return true;
|
||||
const path = require("node:path");
|
||||
const home = path.join(path.resolve(root), req.user.username);
|
||||
const rel = path.relative(home, path.resolve(dir));
|
||||
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
|
||||
if (rel === "") return allowHome || !require("./linux-accounts").s3Enabled();
|
||||
return rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,7 +337,58 @@ function folderTakenByOther(req, dir) {
|
||||
.some((r) => typeof r.cwd === "string" && (related(r.cwd, mine) || related(mine, r.cwd)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Server mode: whether a non-admin may name `dir` — only inside one of their
|
||||
* own workspaces' folders, compared by real path so a symlink in their folder
|
||||
* cannot point the check elsewhere. An analyst's GET too (a path is read with
|
||||
* the dashboard's rights, see `ownFolder`). Always true for an admin.
|
||||
*/
|
||||
function insideOwnWorkspace(req, dir) {
|
||||
const u = req?.user;
|
||||
if (!u || u.role === "admin") return true;
|
||||
return insideLanes(
|
||||
sql("SELECT id FROM lanes WHERE owner_id = ?")
|
||||
.all(u.id)
|
||||
.map((r) => r.id),
|
||||
dir
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server mode: Express middleware for the config readers (`/cc-config`,
|
||||
* `/codex-config`) — a non-admin's `?cwd=` (an analyst's GET included) must be
|
||||
* inside one of THEIR OWN workspaces, else 403 EFOLDER. A caller-chosen
|
||||
* folder is a path the dashboard reads as itself: under S3, with
|
||||
* CAP_DAC_READ_SEARCH, one planted link (`/tmp/a/.claude -> /`) made it a
|
||||
* whole-machine read. An admin, and local mode, pass.
|
||||
*/
|
||||
function ownCwdQuery(req, res, next) {
|
||||
const asked = typeof req.query?.cwd === "string" && req.query.cwd;
|
||||
if (!asked || !req.user || req.user.role === "admin") return next();
|
||||
const own = sql("SELECT id FROM lanes WHERE owner_id = ?")
|
||||
.all(req.user.id)
|
||||
.map((r) => r.id);
|
||||
if (require("node:path").isAbsolute(asked) && insideLanes(own, asked)) return next();
|
||||
res.status(403).json({
|
||||
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
|
||||
});
|
||||
}
|
||||
|
||||
function insideLanes(ids, dir) {
|
||||
const path = require("node:path");
|
||||
const lanesLib = require("./lanes");
|
||||
const real = lanesLib.canonicalCwd(dir);
|
||||
return ids.some((id) => {
|
||||
const cwd = lanesLib.getLane(id)?.cwd;
|
||||
if (!cwd) return false;
|
||||
const rel = path.relative(cwd, real);
|
||||
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
insideOwnWorkspace,
|
||||
ownCwdQuery,
|
||||
folderTakenByOther,
|
||||
scopeGuard,
|
||||
broadcastAudience,
|
||||
@@ -332,6 +398,7 @@ module.exports = {
|
||||
canSeeRowSession,
|
||||
canSeeRunRow,
|
||||
folderAllowed,
|
||||
ownFolder,
|
||||
memberId,
|
||||
ownerFor,
|
||||
sessionWhere,
|
||||
|
||||
Reference in New Issue
Block a user