From 52c307797f2721812a4e70d7c28dda60b0b3ae27 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 27 Oct 2026 14:25:55 -0400 Subject: [PATCH 01/30] feat(github): limit whose PR feedback wakes agents Agents act on the comments a PR watch quotes, so anyone able to comment on a public pull request could steer them. Settings > Plugins > GitHub comment authors now holds trusted organizations, trusted logins and ignored logins. Once a trusted entry is saved, only trusted logins, members of a trusted organization and the signed-in account wake agents; ignored logins never do. Membership is read through gh, batched and kept for an hour, and feedback whose author's membership cannot be read waits for a later poll. Each poll rereads the settings. Co-authored-by: Poppy Co-Authored-By: Claude Opus 5.5 Signed-off-by: John Tennant --- docs/agents.md | 10 ++ src/app/pages.integration.test.mjs | 16 ++- src/bundled/github/AuthorSettings.test.tsx | 50 +++++++ src/bundled/github/AuthorSettings.tsx | 108 ++++++++++++++ src/bundled/github/authors.test.ts | 95 +++++++++++++ src/bundled/github/authors.ts | 158 +++++++++++++++++++++ src/bundled/github/index.tsx | 12 ++ src/bundled/github/watch.ts | 13 +- src/bundled/github/watcher.test.ts | 65 ++++++++- src/bundled/github/watcher.ts | 73 +++++++++- 10 files changed, 588 insertions(+), 12 deletions(-) create mode 100644 src/bundled/github/AuthorSettings.test.tsx create mode 100644 src/bundled/github/AuthorSettings.tsx create mode 100644 src/bundled/github/authors.test.ts create mode 100644 src/bundled/github/authors.ts diff --git a/docs/agents.md b/docs/agents.md index d0f4a6cfeb..243635147f 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -126,6 +126,16 @@ other comments wake the agent. A watch is saved in Attention on the device and r pull request merges or closes, after 20 wakes, after 30 failed polls in a row, or when the agent calls it with `stop: true`. +Settings β†’ Plugins β†’ GitHub comment authors limits whose feedback wakes agents, +since anyone can comment on a public pull request. It holds trusted +organizations, trusted logins (a bot's ends in `[bot]`) and ignored logins, +saved on the device. Ignored logins never wake an agent. Until a trusted +organization or login is saved, everyone else does; after, only trusted logins, +members of a trusted organization and the signed-in account do. Membership is +read through `gh` and kept for an hour; feedback whose author's membership +cannot be read waits for a later poll. Each poll reads the settings again, so a +change applies to running watches. CI and pull request state are not limited. + The Agents runner starts a mention run from a `p` tag, or from any new message by the other member of a one-to-one DM the agent is in; group DMs still need the mention. It also owns watches and timers, and runs attention work in Janet's lanes: each diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 8431eede7f..98fd08707f 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -135,7 +135,13 @@ test("the app runtime exposes ready bundled pages and removes them on disable", .snapshot() .map((card) => card.pluginId) .sort(), - ["block.builderlab", "buzz.channels", "buzz.emoji", "buzz.pairing"], + [ + "block.builderlab", + "buzz.channels", + "buzz.emoji", + "buzz.github", + "buzz.pairing", + ], ); // The device-local template library remains reachable without a selected community. assert.equal( @@ -201,7 +207,13 @@ test("the app runtime exposes ready bundled pages and removes them on disable", .snapshot() .map((card) => card.pluginId) .sort(), - ["block.builderlab", "buzz.channels", "buzz.emoji", "buzz.pairing"], + [ + "block.builderlab", + "buzz.channels", + "buzz.emoji", + "buzz.github", + "buzz.pairing", + ], ); await vi.waitFor(() => diff --git a/src/bundled/github/AuthorSettings.test.tsx b/src/bundled/github/AuthorSettings.test.tsx new file mode 100644 index 0000000000..d9be2947ab --- /dev/null +++ b/src/bundled/github/AuthorSettings.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it } from "vitest"; +import { AuthorSettings } from "./AuthorSettings"; +import { loadPolicy } from "./authors"; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +it("warns until an allowlist is saved, and saves only valid logins", async () => { + const user = userEvent.setup(); + render( true} />); + expect(screen.getByRole("status")).toHaveTextContent( + "comments from anyone wake your agents", + ); + const save = screen.getByRole("button", { name: "Save" }); + expect(save).toBeDisabled(); + await user.type( + screen.getByRole("textbox", { name: /Trusted commenters/ }), + "two words", + ); + await user.click(save); + expect(screen.getByRole("alert")).toHaveTextContent( + "two words is not a GitHub login", + ); + expect(loadPolicy().trustedAuthors).toEqual([]); + const commenters = screen.getByRole("textbox", { + name: /Trusted commenters/, + }); + await user.clear(commenters); + await user.type(commenters, "wes{Enter}copilot[[bot]"); + await user.type( + screen.getByRole("textbox", { name: /Trusted organizations/ }), + "block", + ); + await user.click(save); + expect(loadPolicy()).toEqual({ + version: 1, + trustedOrganizations: ["block"], + trustedAuthors: ["wes", "copilot[bot]"], + ignoredAuthors: [], + }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(save).toBeDisabled(); +}); diff --git a/src/bundled/github/AuthorSettings.tsx b/src/bundled/github/AuthorSettings.tsx new file mode 100644 index 0000000000..1d15991619 --- /dev/null +++ b/src/bundled/github/AuthorSettings.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Header } from "../../shared/design-system/ui/Header"; +import { Textarea } from "../../shared/design-system/ui/Textarea"; +import { + limited, + loadPolicy, + savePolicy, + validate, + type AuthorPolicy, +} from "./authors"; + +type Lists = Omit; +const FIELDS: [keyof Lists, string, string][] = [ + [ + "trustedOrganizations", + "Trusted organizations", + "One GitHub organization login per line. Their members' comments wake your agents.", + ], + [ + "trustedAuthors", + "Trusted commenters", + "One exact GitHub login per line. A bot's ends in [bot].", + ], + [ + "ignoredAuthors", + "Ignored commenters", + "One exact GitHub login per line. These never wake agents, even in a trusted organization.", + ], +]; +const lines = (value: string) => + value + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +const text = (policy: AuthorPolicy) => + Object.fromEntries( + FIELDS.map(([key]) => [key, policy[key].join("\n")]), + ) as Record; + +/** Whose pull request comments and reviews wake agents watching it. */ +export function AuthorSettings({ active }: { active(): boolean }) { + const [saved, setSaved] = useState(loadPolicy); + const [draft, setDraft] = useState(() => text(saved)); + const [error, setError] = useState(""); + const policy: AuthorPolicy = { + version: 1, + trustedOrganizations: lines(draft.trustedOrganizations), + trustedAuthors: lines(draft.trustedAuthors), + ignoredAuthors: lines(draft.ignoredAuthors), + }; + const dirty = JSON.stringify(policy) !== JSON.stringify(saved); + return ( +
+
+
{ + event.preventDefault(); + if (!active() || !dirty) return; + const invalid = validate(policy); + if (invalid) return setError(invalid); + savePolicy(policy); + setSaved(policy); + setDraft(text(policy)); + setError(""); + }} + > + {!limited(saved) && ( +

+ No allowlist is set, so comments from anyone wake your agents. Add a + trusted organization or commenter to limit them. +

+ )} + {FIELDS.map(([key, label, description]) => ( + +