Skip to content

Commit 9b307d8

Browse files
authored
feat(commands): let a PR's own author use chat when rate limiting is active (#5087)
@gittensory chat was maintainer/collaborator-only, silently denying the PR's own author with no reply. Widens chat's default roles to include pr_author, but gates the grant on commandRateLimitPolicy being "hold" for the repo -- enforced in evaluateCommandAuthorization, not just by operator convention, so a deployment that hasn't turned on rate limiting never grants contributor chat access regardless of what chat's configured roles say. Also fixes normalizeCommandRoleList's clamp so a maintainer's yml restatement of chat's own default (now including pr_author) isn't silently mangled, while every other maintainer-only command is unaffected. Closes #5084
1 parent 0800f23 commit 9b307d8

8 files changed

Lines changed: 252 additions & 36 deletions

File tree

packages/gittensory-engine/src/settings/command-authorization.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ export const DEFAULT_COMMAND_AUTHORIZATION_POLICY: RepositoryCommandAuthorizatio
1414
"noise-report": ["maintainer", "collaborator"],
1515
"gate-override": ["maintainer", "collaborator"],
1616
plan: ["maintainer", "collaborator"],
17-
// #4595: deliberately narrower than "ask"'s default (which allows confirmed_miner) -- chat is Ollama-only
18-
// grounded LLM generation, a materially larger surface than ask's deterministic-only answer, so v1 starts
19-
// maintainer/collaborator-only. Explicit registration here (rather than falling through to `default`) also
20-
// activates the pr_author-widening guard below, so a self-hoster can't accidentally yml themselves into
21-
// "anyone commenting on their own PR" without it.
22-
chat: ["maintainer", "collaborator"],
17+
// #4595/#5084: chat is Ollama-only grounded LLM generation, a materially larger surface than ask's
18+
// deterministic-only answer, so v1 started maintainer/collaborator-only. #5084 widens this to the PR's
19+
// OWN author (never an arbitrary commenter on someone else's PR) -- but ONLY when commandRateLimitPolicy
20+
// is "hold" for the repo, enforced in evaluateCommandAuthorization below, not just by operator convention.
21+
// Explicit registration here (rather than falling through to `default`) also activates the
22+
// MAINTAINER_ONLY_DEFAULT_COMMANDS clamp in normalizeCommandRoleList, so a self-hoster can't yml
23+
// themselves into "any confirmed_miner" or similar widening beyond what's shipped here.
24+
chat: ["maintainer", "collaborator", "pr_author"],
2325
// #1960 PR control-surface verbs. "review" is deliberately widenable to confirmed_miner (same self-rerun
2426
// precedent already applied to review-now, #824) — a confirmed miner may re-trigger review on their own PR.
2527
// The rest (pause/resume/resolve/configuration/explain) are conservative maintainer/collaborator-only
@@ -46,6 +48,11 @@ const COMMAND_AUTHORIZATION_ROLES = new Set<CommandAuthorizationRole>(["maintain
4648
// plain `pr_author` role; `confirmed_miner` survives so a detected miner can self-trigger reruns (#824).
4749
const MAINTAINER_COMMAND_AUTHORIZATION_ROLES = new Set<CommandAuthorizationRole>(["maintainer", "collaborator", "confirmed_miner"]);
4850
const MAINTAINER_ONLY_DEFAULT_COMMANDS = new Set(Object.keys(DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands));
51+
// #5084: commands where a `pr_author` match is only actually granted when commandRateLimitPolicy is "hold" for
52+
// the repo -- checked in evaluateCommandAuthorization. Currently just `chat` (Ollama-only LLM generation);
53+
// deliberately a narrow, explicit allowlist rather than inferring this from isAiCostBearingCommand, so widening
54+
// it to another command later is a deliberate one-line addition, not an implicit side effect of an unrelated set.
55+
const PR_AUTHOR_RATE_LIMITED_COMMANDS = new Set(["chat"]);
4956

5057
export type CommandAuthorizationDecision = {
5158
authorized: boolean;
@@ -117,11 +124,20 @@ export function evaluateCommandAuthorization(args: {
117124
commenterAssociation?: string | null | undefined;
118125
pullRequestAuthorLogin?: string | null | undefined;
119126
minerStatus?: "confirmed" | "not_found" | "unavailable" | undefined;
127+
/** #5084: required (must be `"hold"`) for a bare `pr_author` match to actually authorize a command in
128+
* {@link PR_AUTHOR_RATE_LIMITED_COMMANDS} (currently just `chat`) -- unset/`"off"` denies exactly as if
129+
* `pr_author` weren't in the allowed-roles list at all, so a repo that hasn't turned on rate limiting
130+
* never grants contributor chat access no matter what `chat`'s configured roles say. */
131+
commandRateLimitPolicy?: "off" | "hold" | undefined;
120132
}): CommandAuthorizationDecision {
121133
const allowedRoles = commandAuthorizationAllowedRoles(args.policy, args.commandName);
122134
const roles = actorRoles(args);
123135
const matchedRole = roles.find((role) => allowedRoles.includes(role)) ?? null;
124-
if (matchedRole) {
136+
const prAuthorRateLimitGated =
137+
matchedRole === "pr_author" &&
138+
PR_AUTHOR_RATE_LIMITED_COMMANDS.has(normalizeCommandName(args.commandName)) &&
139+
args.commandRateLimitPolicy !== "hold";
140+
if (matchedRole && !prAuthorRateLimitGated) {
125141
return {
126142
authorized: true,
127143
reason: authorizationReason(matchedRole),
@@ -130,6 +146,9 @@ export function evaluateCommandAuthorization(args: {
130146
allowedRoles,
131147
};
132148
}
149+
if (prAuthorRateLimitGated) {
150+
return { authorized: false, reason: "pr_author_requires_rate_limiting", actorKind: "author", matchedRole: null, allowedRoles };
151+
}
133152
const ownPrAuthor = isSameLogin(args.commenterLogin, args.pullRequestAuthorLogin);
134153
if (ownPrAuthor && allowedRoles.includes("confirmed_miner")) {
135154
return {
@@ -168,7 +187,15 @@ export function summarizeCommandAuthorizationPolicy(policy: RepositoryCommandAut
168187
function normalizeCommandRoleList(commandName: string, roles: CommandAuthorizationRole[], warnings: string[]): CommandAuthorizationRole[] {
169188
if (!MAINTAINER_ONLY_DEFAULT_COMMANDS.has(commandName)) return roles;
170189

171-
const maintainerRoles = roles.filter((role) => MAINTAINER_COMMAND_AUTHORIZATION_ROLES.has(role));
190+
// #5084: a role also survives the clamp if it's explicitly part of THIS command's own shipped default
191+
// (chat's own default now includes pr_author) -- so a maintainer restating or narrowing a command's own
192+
// default via yml never gets silently mangled, while every OTHER maintainer-only command whose own default
193+
// excludes pr_author still can't have it added via override (this is a per-command union, not a blanket
194+
// relaxation: the clamp still can't be conjured up on generate-tests/pause/etc.).
195+
/* v8 ignore next -- defensive: MAINTAINER_ONLY_DEFAULT_COMMANDS is derived from these keys, so a maintainer-only command always resolves a default list. */
196+
const commandOwnDefaultRoles = DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands[commandName] ?? [];
197+
const allowedClampRoles = new Set<CommandAuthorizationRole>([...MAINTAINER_COMMAND_AUTHORIZATION_ROLES, ...commandOwnDefaultRoles]);
198+
const maintainerRoles = roles.filter((role) => allowedClampRoles.has(role));
172199
if (maintainerRoles.length === roles.length) return roles;
173200

174201
warnings.push(`Ignored author command authorization roles for maintainer-only command: ${commandName}.`);

src/github/commands.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,9 @@ export function isAuthorizedCommandActor(args: {
382382
pullRequestAuthorLogin?: string | null | undefined;
383383
officialAuthorDetection?: OfficialGittensorMinerDetection | undefined;
384384
commandAuthorizationPolicy?: RepositoryCommandAuthorizationPolicy | null | undefined;
385+
/** #5084: required (must be `"hold"`) for a PR author to be authorized for `chat` -- see
386+
* PR_AUTHOR_RATE_LIMITED_COMMANDS in settings/command-authorization.ts. */
387+
commandRateLimitPolicy?: "off" | "hold" | undefined;
385388
}): { authorized: boolean; reason: string; actorKind: "maintainer" | "author" | "none" } {
386389
const decision = evaluateCommandAuthorization({
387390
policy: args.commandAuthorizationPolicy,
@@ -390,6 +393,7 @@ export function isAuthorizedCommandActor(args: {
390393
commenterAssociation: args.commenterAssociation,
391394
pullRequestAuthorLogin: args.pullRequestAuthorLogin,
392395
minerStatus: args.officialAuthorDetection?.status,
396+
commandRateLimitPolicy: args.commandRateLimitPolicy,
393397
});
394398
return { authorized: decision.authorized, reason: decision.reason, actorKind: decision.actorKind };
395399
}

src/queue/processors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12560,6 +12560,7 @@ async function maybeProcessGittensoryMentionCommand(
1256012560
pullRequestAuthorLogin: pullRequestAuthor,
1256112561
officialAuthorDetection: official,
1256212562
commandAuthorizationPolicy: settings.commandAuthorization,
12563+
commandRateLimitPolicy: settings.commandRateLimitPolicy,
1256312564
});
1256412565
if (!authorization.authorized) {
1256512566
await recordAuditEvent(env, {

src/settings/command-authorization.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ export const DEFAULT_COMMAND_AUTHORIZATION_POLICY: RepositoryCommandAuthorizatio
1414
"noise-report": ["maintainer", "collaborator"],
1515
"gate-override": ["maintainer", "collaborator"],
1616
plan: ["maintainer", "collaborator"],
17-
// #4595: deliberately narrower than "ask"'s default (which allows confirmed_miner) -- chat is Ollama-only
18-
// grounded LLM generation, a materially larger surface than ask's deterministic-only answer, so v1 starts
19-
// maintainer/collaborator-only. Explicit registration here (rather than falling through to `default`) also
20-
// activates the pr_author-widening guard below, so a self-hoster can't accidentally yml themselves into
21-
// "anyone commenting on their own PR" without it.
22-
chat: ["maintainer", "collaborator"],
17+
// #4595/#5084: chat is Ollama-only grounded LLM generation, a materially larger surface than ask's
18+
// deterministic-only answer, so v1 started maintainer/collaborator-only. #5084 widens this to the PR's
19+
// OWN author (never an arbitrary commenter on someone else's PR) -- but ONLY when commandRateLimitPolicy
20+
// is "hold" for the repo, enforced in evaluateCommandAuthorization below, not just by operator convention.
21+
// Explicit registration here (rather than falling through to `default`) also activates the
22+
// MAINTAINER_ONLY_DEFAULT_COMMANDS clamp in normalizeCommandRoleList, so a self-hoster can't yml
23+
// themselves into "any confirmed_miner" or similar widening beyond what's shipped here.
24+
chat: ["maintainer", "collaborator", "pr_author"],
2325
// #1960 PR control-surface verbs. "review" is deliberately widenable to confirmed_miner (same self-rerun
2426
// precedent already applied to review-now, #824) — a confirmed miner may re-trigger review on their own PR.
2527
// The rest (pause/resume/resolve/configuration/explain) are conservative maintainer/collaborator-only
@@ -46,6 +48,11 @@ const COMMAND_AUTHORIZATION_ROLES = new Set<CommandAuthorizationRole>(["maintain
4648
// plain `pr_author` role; `confirmed_miner` survives so a detected miner can self-trigger reruns (#824).
4749
const MAINTAINER_COMMAND_AUTHORIZATION_ROLES = new Set<CommandAuthorizationRole>(["maintainer", "collaborator", "confirmed_miner"]);
4850
const MAINTAINER_ONLY_DEFAULT_COMMANDS = new Set(Object.keys(DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands));
51+
// #5084: commands where a `pr_author` match is only actually granted when commandRateLimitPolicy is "hold" for
52+
// the repo -- checked in evaluateCommandAuthorization. Currently just `chat` (Ollama-only LLM generation);
53+
// deliberately a narrow, explicit allowlist rather than inferring this from isAiCostBearingCommand, so widening
54+
// it to another command later is a deliberate one-line addition, not an implicit side effect of an unrelated set.
55+
const PR_AUTHOR_RATE_LIMITED_COMMANDS = new Set(["chat"]);
4956

5057
export type CommandAuthorizationDecision = {
5158
authorized: boolean;
@@ -117,11 +124,20 @@ export function evaluateCommandAuthorization(args: {
117124
commenterAssociation?: string | null | undefined;
118125
pullRequestAuthorLogin?: string | null | undefined;
119126
minerStatus?: "confirmed" | "not_found" | "unavailable" | undefined;
127+
/** #5084: required (must be `"hold"`) for a bare `pr_author` match to actually authorize a command in
128+
* {@link PR_AUTHOR_RATE_LIMITED_COMMANDS} (currently just `chat`) -- unset/`"off"` denies exactly as if
129+
* `pr_author` weren't in the allowed-roles list at all, so a repo that hasn't turned on rate limiting
130+
* never grants contributor chat access no matter what `chat`'s configured roles say. */
131+
commandRateLimitPolicy?: "off" | "hold" | undefined;
120132
}): CommandAuthorizationDecision {
121133
const allowedRoles = commandAuthorizationAllowedRoles(args.policy, args.commandName);
122134
const roles = actorRoles(args);
123135
const matchedRole = roles.find((role) => allowedRoles.includes(role)) ?? null;
124-
if (matchedRole) {
136+
const prAuthorRateLimitGated =
137+
matchedRole === "pr_author" &&
138+
PR_AUTHOR_RATE_LIMITED_COMMANDS.has(normalizeCommandName(args.commandName)) &&
139+
args.commandRateLimitPolicy !== "hold";
140+
if (matchedRole && !prAuthorRateLimitGated) {
125141
return {
126142
authorized: true,
127143
reason: authorizationReason(matchedRole),
@@ -130,6 +146,9 @@ export function evaluateCommandAuthorization(args: {
130146
allowedRoles,
131147
};
132148
}
149+
if (prAuthorRateLimitGated) {
150+
return { authorized: false, reason: "pr_author_requires_rate_limiting", actorKind: "author", matchedRole: null, allowedRoles };
151+
}
133152
const ownPrAuthor = isSameLogin(args.commenterLogin, args.pullRequestAuthorLogin);
134153
if (ownPrAuthor && allowedRoles.includes("confirmed_miner")) {
135154
return {
@@ -168,7 +187,15 @@ export function summarizeCommandAuthorizationPolicy(policy: RepositoryCommandAut
168187
function normalizeCommandRoleList(commandName: string, roles: CommandAuthorizationRole[], warnings: string[]): CommandAuthorizationRole[] {
169188
if (!MAINTAINER_ONLY_DEFAULT_COMMANDS.has(commandName)) return roles;
170189

171-
const maintainerRoles = roles.filter((role) => MAINTAINER_COMMAND_AUTHORIZATION_ROLES.has(role));
190+
// #5084: a role also survives the clamp if it's explicitly part of THIS command's own shipped default
191+
// (chat's own default now includes pr_author) -- so a maintainer restating or narrowing a command's own
192+
// default via yml never gets silently mangled, while every OTHER maintainer-only command whose own default
193+
// excludes pr_author still can't have it added via override (this is a per-command union, not a blanket
194+
// relaxation: the clamp still can't be conjured up on generate-tests/pause/etc.).
195+
/* v8 ignore next -- defensive: MAINTAINER_ONLY_DEFAULT_COMMANDS is derived from these keys, so a maintainer-only command always resolves a default list. */
196+
const commandOwnDefaultRoles = DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands[commandName] ?? [];
197+
const allowedClampRoles = new Set<CommandAuthorizationRole>([...MAINTAINER_COMMAND_AUTHORIZATION_ROLES, ...commandOwnDefaultRoles]);
198+
const maintainerRoles = roles.filter((role) => allowedClampRoles.has(role));
172199
if (maintainerRoles.length === roles.length) return roles;
173200

174201
warnings.push(`Ignored author command authorization roles for maintainer-only command: ${commandName}.`);

test/unit/command-authorization-engine.test.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -186,20 +186,52 @@ describe("repo command authorization policy", () => {
186186
expect(clamped.policy.commands.review).toEqual(["confirmed_miner"]);
187187
});
188188

189-
it("#4595: chat defaults to maintainer/collaborator-only, deliberately excluding confirmed_miner (unlike ask's default)", () => {
190-
expect(commandAuthorizationAllowedRoles(undefined, "chat")).toEqual(["maintainer", "collaborator"]);
189+
it("#4595/#5084: chat defaults to maintainer/collaborator/pr_author (unlike ask's default, no confirmed_miner)", () => {
190+
expect(commandAuthorizationAllowedRoles(undefined, "chat")).toEqual(["maintainer", "collaborator", "pr_author"]);
191191
expect(evaluateCommandAuthorization({ commandName: "chat", commenterAssociation: "OWNER" })).toMatchObject({ authorized: true, reason: "maintainer_invocation", actorKind: "maintainer" });
192192
expect(evaluateCommandAuthorization({ commandName: "chat", commenterAssociation: "COLLABORATOR" })).toMatchObject({ authorized: true, reason: "collaborator_invocation", actorKind: "maintainer" });
193-
// A confirmed-miner PR author is denied on chat (unlike "review"): confirmed_miner is not in chat's default
194-
// allowed-roles list, so the pr_author-widening guard denies it the same as any other non-maintainer author.
193+
});
194+
195+
it("#5084: a chat pr_author match is only granted when commandRateLimitPolicy is \"hold\" for the repo", () => {
196+
// No rate-limit policy passed at all (the undefined branch) -- denied, with a distinct reason from the
197+
// generic denials so an operator can tell "rate limiting isn't on" apart from "not authorized at all".
198+
expect(
199+
evaluateCommandAuthorization({ commandName: "chat", commenterLogin: "author", pullRequestAuthorLogin: "author" }),
200+
).toMatchObject({ authorized: false, reason: "pr_author_requires_rate_limiting", actorKind: "author", matchedRole: null });
201+
// Explicitly "off" (not just unset) -- same denial, covering both falsy branches of the `!== "hold"` check.
202+
expect(
203+
evaluateCommandAuthorization({ commandName: "chat", commenterLogin: "author", pullRequestAuthorLogin: "author", commandRateLimitPolicy: "off" }),
204+
).toMatchObject({ authorized: false, reason: "pr_author_requires_rate_limiting" });
205+
// "hold" -- the PR's own author is authorized.
206+
expect(
207+
evaluateCommandAuthorization({ commandName: "chat", commenterLogin: "author", pullRequestAuthorLogin: "author", commandRateLimitPolicy: "hold" }),
208+
).toMatchObject({ authorized: true, reason: "allowed_pr_author", actorKind: "author", matchedRole: "pr_author" });
209+
// A confirmed miner acting on their OWN PR matches pr_author first (chat's roles list has pr_author, not
210+
// confirmed_miner) -- so a miner is gated by the SAME rate-limit requirement as any other PR author, not
211+
// the separate confirmed_miner exception "review" gets.
195212
expect(
196213
evaluateCommandAuthorization({ commandName: "chat", commenterLogin: "miner", pullRequestAuthorLogin: "miner", minerStatus: "confirmed" }),
197-
).toMatchObject({ authorized: false, reason: "maintainer_command_requires_maintainer", actorKind: "author" });
198-
// A spoofable pr_author role added via override is clamped off with a warning, same as every other
199-
// maintainer-only default command.
200-
const clamped = normalizeCommandAuthorizationPolicy({ commands: { chat: ["collaborator", "pr_author"] } });
201-
expect(clamped.warnings).toContain("Ignored author command authorization roles for maintainer-only command: chat.");
202-
expect(clamped.policy.commands.chat).toEqual(["collaborator"]);
214+
).toMatchObject({ authorized: false, reason: "pr_author_requires_rate_limiting" });
215+
expect(
216+
evaluateCommandAuthorization({ commandName: "chat", commenterLogin: "miner", pullRequestAuthorLogin: "miner", minerStatus: "confirmed", commandRateLimitPolicy: "hold" }),
217+
).toMatchObject({ authorized: true, reason: "allowed_pr_author", matchedRole: "pr_author" });
218+
// A commenter on someone ELSE's PR is still denied outright -- pr_author never matches for a non-author,
219+
// rate limiting or not.
220+
expect(
221+
evaluateCommandAuthorization({ commandName: "chat", commenterLogin: "other", pullRequestAuthorLogin: "author", commandRateLimitPolicy: "hold" }),
222+
).toMatchObject({ authorized: false, reason: "not_maintainer_or_pr_author" });
223+
});
224+
225+
it("#5084: a maintainer's yml override restating chat's own default (incl. pr_author) is not clamped away", () => {
226+
const restated = normalizeCommandAuthorizationPolicy({ commands: { chat: ["collaborator", "pr_author"] } });
227+
expect(restated.warnings).not.toContain("Ignored author command authorization roles for maintainer-only command: chat.");
228+
expect(restated.policy.commands.chat).toEqual(["collaborator", "pr_author"]);
229+
// But every OTHER maintainer-only command's own shipped default still excludes pr_author, so the SAME
230+
// override shape on a different command is still clamped -- this is a per-command union, not a blanket
231+
// relaxation of the clamp.
232+
const otherCommand = normalizeCommandAuthorizationPolicy({ commands: { "queue-summary": ["collaborator", "pr_author"] } });
233+
expect(otherCommand.warnings).toContain("Ignored author command authorization roles for maintainer-only command: queue-summary.");
234+
expect(otherCommand.policy.commands["queue-summary"]).toEqual(["collaborator"]);
203235
});
204236

205237
it("falls back to default roles for inherited object property command names", () => {

0 commit comments

Comments
 (0)