Files
AgentHub/server/lib/hook-auth.js
T
nntrivi2001 84e2a9f0ac 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>
2026-10-09 14:57:10 +07:00

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,
};