#!/usr/bin/env node /** * @file member-guard.js * @description Server mode, one Linux user per account (S3): the members' * SYNCHRONOUS Claude Code hook, from /etc/claude-code/managed-settings.json * (install-system.sh), run AS the member from /opt/agenthub/app. The box is * shared and small, so it is for writing code only: * * - `PreToolUse` (matcher Bash): denies a test run or a container command * (`isHeavy`) — the member commits, pulls the branch on their own machine * and runs it there. Claude gets the reason and stops trying. An admin * can allow them again (Settings → AI tools, server/lib/member-policy.js). * - `UserPromptSubmit`: a member runs at most `MAX_AI_SESSIONS` AI sessions * at once (server/lib/ai-procs.js). The dashboard refuses a pane past the * limit itself (ESESSIONLIMIT); this catches a `claude` typed into a * Terminal tab, by refusing every prompt of a session that is not among * the account's oldest `limit` ones. * * Only `ah-*` accounts are guarded (the host is an admin's). Fail-open like * every AgentHub hook: any error lets the call through. A person's own * terminal commands and Codex (whose hooks are opt-in) are not covered. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const path = require("node:path"); const procs = require(path.join(__dirname, "..", "server", "lib", "ai-procs.js")); const policy = require(path.join(__dirname, "..", "server", "lib", "member-policy.js")); /** Test runners and container tools, by the program a command segment runs. */ const HEAVY_BINS = new Set([ "vitest", "jest", "mocha", "ava", "karma", "playwright", "cypress", "phpunit", "pest", "paratest", "pytest", "py.test", "tox", "nox", "rspec", "ctest", "nextest", "docker", "docker-compose", "podman", "podman-compose", "nerdctl", "buildah", "kind", "minikube", "k3d", ]); /** Wrappers that run the command after them. */ const PASS_THROUGH = new Set([ "sudo", "env", "time", "nice", "ionice", "timeout", "npx", "bunx", "pnpx", "exec", "command", "xvfb-run", "stdbuf", ]); const TEST_SCRIPT = /^(test|tests|e2e|spec|unit|integration)([:._-].*)?$/; /** The words of one command segment, wrappers and env assignments dropped. */ function words(segment) { const w = segment .trim() .split(/\s+/) .map((x) => x.replace(/^['"]|['"]$/g, "")) .filter(Boolean); for (;;) { while (w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w.shift(); if (!w.length || !PASS_THROUGH.has(path.basename(w[0]))) break; w.shift(); // a wrapper's own options and arguments (`timeout 60`, `nice -n 5`, `npx -y`) while (w.length && (w[0].startsWith("-") || /^\d+[smhd]?$/.test(w[0]))) w.shift(); } return w; } /** Does this one segment run tests or containers? */ function segmentIsHeavy(segment) { const w = words(segment); if (!w.length) return false; const bin = path.basename(w[0]); const [a, b] = [w[1] || "", w[2] || ""]; if (HEAVY_BINS.has(bin)) return true; if (["npm", "pnpm", "yarn", "bun", "composer"].includes(bin)) { if (a === "t" || TEST_SCRIPT.test(a)) return true; // npm test, yarn test:unit if (["run", "run-script", "exec", "x", "dlx"].includes(a)) { return TEST_SCRIPT.test(b) || HEAVY_BINS.has(path.basename(b)); } return false; } if (["go", "deno", "dotnet", "mvn", "mvnw", "gradle", "gradlew"].includes(bin)) { return ["test", "verify", "integrationTest"].includes(a); } if (bin === "cargo") return a === "test" || a === "nextest"; if (bin === "make") return w.slice(1).some((x) => TEST_SCRIPT.test(x) || x === "check"); if (bin === "php" || bin === "artisan") { const rest = bin === "php" ? w.slice(1) : w; const i = rest.findIndex((x) => /(^|\/)artisan$/.test(x)); if (i !== -1 && TEST_SCRIPT.test(rest[i + 1] || "")) return true; return rest.some((x) => HEAVY_BINS.has(path.basename(x))); } if (/^python[0-9.]*$/.test(bin)) { const i = w.indexOf("-m"); return i !== -1 && ["pytest", "unittest", "tox", "nox"].includes(w[i + 1]); } return false; } /** Does a Bash command run tests or containers anywhere in it? */ function isHeavy(command) { if (typeof command !== "string") return false; // Inner `bash -c "..."` / `sh -c '...'` bodies are checked too. const inner = [...command.matchAll(/\b(?:ba|z|da)?sh\s+-l?c\s+(["'])([\s\S]*?)\1/g)].map( (m) => m[2] ); return [command, ...inner].some((c) => c.split(/&&|\|\||[;|&\n()]|\$\(|`/).some(segmentIsHeavy)); } const DENY_REASON = "AgentHub: this shared server is for writing code only — tests and containers " + "(vitest, jest, phpunit, pytest, npm test, docker, …) are not run here, because " + "a few of them at once freeze it for the whole team. Do not try another way to " + "run them. Finish the change, tell the user which tests to run, and let them " + "commit, pull the branch on their own machine and test there."; /** The admin's switch (Settings → AI tools), beside this script in the app root. */ const testsAllowed = () => policy.read(path.join(__dirname, "..", policy.FILE)).allowTests === true; function guardTool(input, allowed = testsAllowed()) { if (allowed || input.tool_name !== "Bash" || !isHeavy(input.tool_input?.command)) return; process.stdout.write( JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: DENY_REASON, }, }) + "\n" ); } function guardPrompt() { const limit = procs.sessionLimit(); if (!limit) return; const mine = procs.sessionOf(process.pid); if (mine == null) return; const all = procs.sessionsOf(process.getuid()); const at = all.findIndex((s) => s.pid === mine); if (at < limit) return; // among the oldest `limit` (or not found: let it be) process.stderr.write( `AgentHub: you are running ${all.length} AI sessions — at most ${limit} at once on this ` + "server, and this one started past the limit. Close another session (or this one) first.\n" ); process.exit(2); } function main() { const event = process.argv[2]; let raw = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (c) => (raw += c)); process.stdin.on("end", () => { try { if (!os.userInfo().username.startsWith("ah-")) return; if (event === "PreToolUse") guardTool(JSON.parse(raw || "{}")); else if (event === "UserPromptSubmit") guardPrompt(); } catch { /* fail-open */ } }); } if (require.main === module) main(); module.exports = { isHeavy, segmentIsHeavy, words, guardTool };