chore(security): add guardrails and PR merge summary docs

This commit is contained in:
2026-02-25 22:05:22 +00:00
parent 8097ab7484
commit a2f88cfe0f
4 changed files with 107 additions and 6 deletions

View File

@@ -13,3 +13,4 @@
- `channel-private-create` (create private channel for allowlist) - `channel-private-create` (create private channel for allowlist)
- `channel-private-update` (update allowlist/overwrites for existing channel) - `channel-private-update` (update allowlist/overwrites for existing channel)
- `member-list` (guild members list with pagination + optional field projection) - `member-list` (guild members list with pagination + optional field projection)
- guardrails: action mode validation, id-list limits, response-size limit

View File

@@ -30,6 +30,10 @@ const BIT_VIEW_CHANNEL = 1024n;
const BIT_SEND_MESSAGES = 2048n; const BIT_SEND_MESSAGES = 2048n;
const BIT_READ_MESSAGE_HISTORY = 65536n; const BIT_READ_MESSAGE_HISTORY = 65536n;
const MAX_MEMBER_FIELDS = Math.max(1, Number(process.env.MAX_MEMBER_FIELDS || 20));
const MAX_MEMBER_RESPONSE_BYTES = Math.max(2048, Number(process.env.MAX_MEMBER_RESPONSE_BYTES || 500000));
const MAX_PRIVATE_MUTATION_TARGETS = Math.max(1, Number(process.env.MAX_PRIVATE_MUTATION_TARGETS || 200));
function sendJson(res, status, payload) { function sendJson(res, status, payload) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(payload)); res.end(JSON.stringify(payload));
@@ -148,6 +152,18 @@ function parseFieldList(input) {
return []; return [];
} }
function normalizeIdList(value, label) {
const arr = Array.isArray(value) ? value.map(String).map((v) => v.trim()).filter(Boolean) : [];
if (arr.length > MAX_PRIVATE_MUTATION_TARGETS) {
throw fail(400, "bad_request", `${label} exceeds MAX_PRIVATE_MUTATION_TARGETS`, {
label,
limit: MAX_PRIVATE_MUTATION_TARGETS,
size: arr.length,
});
}
return arr;
}
function pick(obj, keys) { function pick(obj, keys) {
if (!obj || typeof obj !== "object") return obj; if (!obj || typeof obj !== "object") return obj;
const out = {}; const out = {};
@@ -186,8 +202,8 @@ async function actionChannelPrivateCreate(body) {
nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined, nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined,
permission_overwrites: buildPrivateOverwrites({ permission_overwrites: buildPrivateOverwrites({
guildId, guildId,
allowedUserIds: Array.isArray(body.allowedUserIds) ? body.allowedUserIds.map(String) : [], allowedUserIds: normalizeIdList(body.allowedUserIds, "allowedUserIds"),
allowedRoleIds: Array.isArray(body.allowedRoleIds) ? body.allowedRoleIds.map(String) : [], allowedRoleIds: normalizeIdList(body.allowedRoleIds, "allowedRoleIds"),
allowMask: body.allowMask, allowMask: body.allowMask,
denyEveryoneMask: body.denyEveryoneMask, denyEveryoneMask: body.denyEveryoneMask,
}), }),
@@ -215,10 +231,13 @@ async function actionChannelPrivateUpdate(body) {
const allowMask = toStringMask(body.allowMask, BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY); const allowMask = toStringMask(body.allowMask, BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY);
const denyMask = toStringMask(body.denyMask, 0n); const denyMask = toStringMask(body.denyMask, 0n);
const mode = String(body.mode || "merge").trim(); const mode = String(body.mode || "merge").trim();
if (mode !== "merge" && mode !== "replace") {
throw fail(400, "bad_request", "mode must be merge or replace", { mode });
}
const addUserIds = Array.isArray(body.addUserIds) ? body.addUserIds.map(String) : []; const addUserIds = normalizeIdList(body.addUserIds, "addUserIds");
const addRoleIds = Array.isArray(body.addRoleIds) ? body.addRoleIds.map(String) : []; const addRoleIds = normalizeIdList(body.addRoleIds, "addRoleIds");
const removeTargetIds = Array.isArray(body.removeTargetIds) ? body.removeTargetIds.map(String) : []; const removeTargetIds = normalizeIdList(body.removeTargetIds, "removeTargetIds");
const existing = await discordRequest(`/channels/${channelId}`); const existing = await discordRequest(`/channels/${channelId}`);
const existingOverwrites = Array.isArray(existing.permission_overwrites) ? existing.permission_overwrites : []; const existingOverwrites = Array.isArray(existing.permission_overwrites) ? existing.permission_overwrites : [];
@@ -275,6 +294,12 @@ async function actionMemberList(body) {
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
const after = body.after ? String(body.after) : undefined; const after = body.after ? String(body.after) : undefined;
const fields = parseFieldList(body.fields); const fields = parseFieldList(body.fields);
if (fields.length > MAX_MEMBER_FIELDS) {
throw fail(400, "bad_request", "fields exceeds MAX_MEMBER_FIELDS", {
limit: MAX_MEMBER_FIELDS,
size: fields.length,
});
}
const qs = new URLSearchParams(); const qs = new URLSearchParams();
qs.set("limit", String(limit)); qs.set("limit", String(limit));
@@ -283,7 +308,7 @@ async function actionMemberList(body) {
const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`); const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`);
const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members; const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members;
return { const response = {
ok: true, ok: true,
action: "member-list", action: "member-list",
guildId, guildId,
@@ -291,6 +316,17 @@ async function actionMemberList(body) {
fields: fields.length ? fields : undefined, fields: fields.length ? fields : undefined,
members: projected, members: projected,
}; };
const bytes = Buffer.byteLength(JSON.stringify(response), "utf8");
if (bytes > MAX_MEMBER_RESPONSE_BYTES) {
throw fail(413, "response_too_large", "member-list response exceeds MAX_MEMBER_RESPONSE_BYTES", {
bytes,
limit: MAX_MEMBER_RESPONSE_BYTES,
hint: "reduce limit or set fields projection",
});
}
return response;
} }
async function handleAction(body) { async function handleAction(body) {
@@ -313,6 +349,11 @@ const server = http.createServer((req, res) => {
authRequired: !!authToken || requireAuthToken, authRequired: !!authToken || requireAuthToken,
actionGates: enabledActions, actionGates: enabledActions,
guildAllowlistEnabled: allowedGuildIds.size > 0, guildAllowlistEnabled: allowedGuildIds.size > 0,
limits: {
maxMemberFields: MAX_MEMBER_FIELDS,
maxMemberResponseBytes: MAX_MEMBER_RESPONSE_BYTES,
maxPrivateMutationTargets: MAX_PRIVATE_MUTATION_TARGETS,
},
}); });
} }

View File

@@ -21,6 +21,10 @@ export AUTH_TOKEN='strong-token'
# optional allowlist # optional allowlist
# export ALLOWED_GUILD_IDS='123,456' # export ALLOWED_GUILD_IDS='123,456'
# export ALLOWED_CALLER_IDS='agent-main,agent-admin' # export ALLOWED_CALLER_IDS='agent-main,agent-admin'
# optional limits
# export MAX_MEMBER_FIELDS=20
# export MAX_MEMBER_RESPONSE_BYTES=500000
# export MAX_PRIVATE_MUTATION_TARGETS=200
node server.mjs node server.mjs
``` ```

55
docs/PR_SUMMARY.md Normal file
View File

@@ -0,0 +1,55 @@
# PR Summary (WhisperGate + Discord Control)
## Scope
This PR delivers two tracks:
1. WhisperGate deterministic no-reply gate for Discord sessions
2. Discord control extension API for private-channel/member-list gaps
## Delivered Features
### WhisperGate
- Deterministic rule chain:
1) non-discord => skip
2) bypass sender => skip
3) ending symbol matched => skip
4) else => no-reply provider/model override
- `NO_REPLY` backend API (`/v1/chat/completions`, `/v1/responses`, `/v1/models`)
- Optional API bearer auth (`AUTH_TOKEN`)
- Prompt prepend on bypass/end-symbol paths:
- `你的这次发言必须以🔚作为结尾。`
- Rule validation script and fixtures
### Discord Control API
- `channel-private-create`
- `channel-private-update` (`merge`/`replace`)
- `member-list` with optional field projection
- Action gate + guild allowlist + caller allowlist + bearer auth
- Dry-run support for channel private actions
## Runtime Mode
- No-Docker-first
- Run directly with Node.js
## Security Defaults (recommended)
- Set `AUTH_TOKEN`
- Set `REQUIRE_AUTH_TOKEN=true`
- Use `ALLOWED_GUILD_IDS`
- Use `ALLOWED_CALLER_IDS`
- Keep Discord bot token in env only (`DISCORD_BOT_TOKEN`)
## Known Limits
- This repo cannot elevate bot privileges; Discord admin permissions still govern all actions.
- `member-list` depends on Discord API permission/intents availability.
## Rollback
- Disable plugin entry or remove plugin path from OpenClaw config
- Stop `discord-control-api` process
- Keep no-reply API stopped if not needed