84e2a9f0ac
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>
159 lines
5.6 KiB
JavaScript
159 lines
5.6 KiB
JavaScript
/**
|
|
* @file hook-auth.js
|
|
* @description Server mode, one Linux user per account (S3 plan, P3d): who
|
|
* posted a hook. Hooks come over loopback HTTP, and once members have their
|
|
* own shells on this machine, loopback alone proves nothing — any account
|
|
* could post a forged event for any session. The kernel knows the answer: the
|
|
* peer of a loopback TCP connection is a socket in `/proc/net/tcp*`, and its
|
|
* row carries the owning uid. No token, no change to any hook script.
|
|
*
|
|
* - the host account (or root): trusted, as before;
|
|
* - a member (`ah-<name>`, enabled): may report only work inside their own
|
|
* home — the hook's `cwd` must be there, a session or a pane it names must
|
|
* be theirs, and a transcript path outside the home is dropped;
|
|
* - anyone else: refused.
|
|
*
|
|
* Off (everything is "host") unless S3 is on.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
// Test seam: the /proc/net/tcp* text.
|
|
let readTables = () =>
|
|
["/proc/net/tcp", "/proc/net/tcp6"].map((f) => {
|
|
try {
|
|
return fs.readFileSync(f, "utf8");
|
|
} catch {
|
|
return "";
|
|
}
|
|
});
|
|
function __setTables(fn) {
|
|
readTables = fn;
|
|
}
|
|
|
|
/**
|
|
* The uid owning the CLIENT end of this loopback connection, or null: the row
|
|
* whose local port is the client's port and whose remote port is ours,
|
|
* ESTABLISHED (`01`).
|
|
*/
|
|
function peerUid(socket) {
|
|
const client = socket?.remotePort;
|
|
const server = socket?.localPort;
|
|
if (!client || !server) return null;
|
|
const hex = (n) => n.toString(16).toUpperCase().padStart(4, "0");
|
|
for (const table of readTables()) {
|
|
for (const line of table.split("\n").slice(1)) {
|
|
const f = line.trim().split(/\s+/);
|
|
if (f.length < 8 || f[3] !== "01") continue;
|
|
// Both ends on loopback: a connection of this machine's to another
|
|
// host's port that happens to match is somebody else's row.
|
|
const [la, lp] = f[1].split(":");
|
|
const [ra, rp] = f[2].split(":");
|
|
if (!LOOPBACK.has(la) || !LOOPBACK.has(ra)) continue;
|
|
if (lp === hex(client) && rp === hex(server)) return Number(f[7]);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 127.0.0.1, ::1 and ::ffff:127.0.0.1 as /proc/net/tcp{,6} print them.
|
|
const LOOPBACK = new Set([
|
|
"0100007F",
|
|
"00000000000000000000000001000000",
|
|
"0000000000000000FFFF00000100007F",
|
|
]);
|
|
|
|
const inside = (home, p) => {
|
|
if (typeof p !== "string" || !path.isAbsolute(p)) return false;
|
|
const rel = path.relative(home, path.resolve(p));
|
|
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
};
|
|
|
|
/**
|
|
* Who posted `req`: `{kind:"host"}`, `{kind:"member", name, home}` or
|
|
* `{kind:"refused"}`. "host" outside S3.
|
|
*/
|
|
let peerImpl = peerUid; // test seam
|
|
function __setPeerUid(fn) {
|
|
peerImpl = fn || peerUid;
|
|
}
|
|
|
|
function hookPeer(req) {
|
|
if (!require("./linux-accounts").s3Enabled()) return { kind: "host" };
|
|
const uid = peerImpl(req.socket);
|
|
if (uid === null) return { kind: "refused" };
|
|
if (uid === 0 || uid === process.getuid()) return { kind: "host" };
|
|
const runAs = require("./run-as");
|
|
const name = runAs.memberNameForUid(uid);
|
|
const conf = require("./linux-accounts").serverConf();
|
|
let acct = null;
|
|
try {
|
|
acct = name ? runAs.accountForPath(path.join(conf.usersRoot, name)) : null;
|
|
} catch {
|
|
/* disabled or gone: refused below */
|
|
}
|
|
return acct && acct.uid === uid ? { kind: "member", name, home: acct.home } : { kind: "refused" };
|
|
}
|
|
|
|
/**
|
|
* S3: whether `req` came from the Linux account the panes of workspace
|
|
* `laneId` run as — its member's, else the host's (this process's uid). A
|
|
* pane token is a bearer secret: whoever holds it acts as the workspace's
|
|
* owner, so it is accepted only from the account its pane runs as. An
|
|
* unknown peer is refused. Always true outside S3.
|
|
*/
|
|
function peerIsPaneAccount(req, laneId) {
|
|
if (!require("./linux-accounts").s3Enabled()) return true;
|
|
const uid = peerImpl(req.socket);
|
|
if (uid === null) return false;
|
|
let acct;
|
|
try {
|
|
acct = require("./run-as").accountForLane(laneId);
|
|
} catch {
|
|
return false; // its member is gone: nobody's pane any more
|
|
}
|
|
return uid === (acct ? acct.uid : process.getuid());
|
|
}
|
|
|
|
/**
|
|
* A member's hook, checked and trimmed in place (`body.data`, `body.run_id`).
|
|
* Returns null when it may go on, else `{status, code, message}`.
|
|
* `data` is the hook's payload after the tool mapping (Claude shape).
|
|
*/
|
|
function checkMemberHook(peer, data, body, { getSession, getRun }) {
|
|
if (peer.kind !== "member") return null;
|
|
const foreign = (message) => ({ status: 403, code: "EFOREIGN", message });
|
|
if (!inside(peer.home, data.cwd))
|
|
return foreign("a hook may only report work in your own folder");
|
|
const existing = typeof data.session_id === "string" ? getSession(data.session_id) : null;
|
|
if (existing && !inside(peer.home, existing.cwd)) return foreign("that session is not yours");
|
|
if (body.run_id != null) {
|
|
const run = getRun(String(body.run_id));
|
|
if (!run || !inside(peer.home, run.cwd)) delete body.run_id;
|
|
}
|
|
dropForeignPaths(peer, data);
|
|
return null;
|
|
}
|
|
|
|
/** A member's hook loses every transcript path outside their home — run
|
|
* BEFORE a tool mapping reads one (Codex's `threadOf`). */
|
|
function dropForeignPaths(peer, data) {
|
|
if (peer.kind !== "member" || !data || typeof data !== "object") return;
|
|
for (const key of ["transcript_path", "agent_transcript_path", "transcriptPath"]) {
|
|
if (key in data && !inside(peer.home, data[key])) delete data[key];
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
peerUid,
|
|
hookPeer,
|
|
peerIsPaneAccount,
|
|
checkMemberHook,
|
|
dropForeignPaths,
|
|
inside,
|
|
__setTables,
|
|
__setPeerUid,
|
|
};
|