497faae3bf
Settings → AI tools → Tests on this server (admin, S3 only). Stored in /opt/agenthub/app/member-policy.json, which member-guard.js reads as the member at each Bash call — no restart; no file = blocked. GET/PUT /api/ai-tools/member-policy, audited, 409 ENOS3 outside S3.
195 lines
6.6 KiB
JavaScript
195 lines
6.6 KiB
JavaScript
#!/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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 };
|