/** * @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-`, 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ĩ */ 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, };