GeezerVison is showing github.com — Fix repository-scoped issue trust (#42) · codeabovelab/codeboost@dbdc917
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: docs/implementation/issue-prioritization.md +20-8Lines changed: 20 additions & 8 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
| 3 | 3 | Baseline: `0ae71a503592de90926063fa563c1f7c715db22b` (`origin/main`, | 4 | 4 | 2026-09-24). | 5 | 5 |
| | 6 | +Follow-up #41 was reproduced from validated-head baseline | | 7 | +`1e59b7dfda199c8156c09a9cb9a690e5b55f1b5e`: GitHub documents `MEMBER` as | | 8 | +organization membership, while its repository-collaborator endpoint reports | | 9 | +the identities with access to the repository. The trust policy below records | | 10 | +the corrected repository-scoped boundary. | | 11 | + | 6 | 12 | ## H1 decision: ranking policy | 7 | 13 |
| 8 | 14 | The first released policy is deterministic, explainable, and independent of an | @@ -26,9 +32,12 @@ Issues sort by descending score, then oldest creation time, then ascending issue | 26 | 32 | number. Every contributing signal is emitted as a user-visible reason. An issue | 27 | 33 | with no contributing signal says that it has no configured priority signals. | 28 | 34 |
| 29 | | -An issue is trusted by default only when GitHub reports its author association | 30 | | -as `OWNER`, `MEMBER`, or `COLLABORATOR`. Other issues remain visible but require | 31 | | -an explicit trust decision before queueing. Trust affects eligibility, never the | | 35 | +An issue is trusted by default only when its author appears in GitHub's current, | | 36 | +repository-scoped collaborator list. GitHub's `author_association` remains | | 37 | +validated and visible metadata, but does not grant trust: in particular, | | 38 | +`MEMBER` proves organization membership rather than access to this repository. | | 39 | +Issues from deleted or non-collaborating authors remain visible but require an | | 40 | +explicit trust decision before queueing. Trust affects eligibility, never the | 32 | 41 | score, so an untrusted author cannot improve rank by embedding instructions in | 33 | 42 | issue text. | 34 | 43 |
| @@ -41,14 +50,17 @@ dedicated read-only gateway with these boundaries: | 41 | 50 |
| 42 | 51 | - Codeboost invokes `gh` with literal arguments; issue text is parsed only as | 43 | 52 | data and is never interpolated into a shell command or prompt. | 44 | | -- The gateway fetches open issues, excludes pull requests, follows bounded | 45 | | - pagination, and validates every field used for normalization or ranking. | | 53 | +- The gateway fetches open issues and the repository's current collaborators, | | 54 | + excludes pull requests, follows bounded pagination for both collections, and | | 55 | + validates every field used for normalization, trust, or ranking. If the | | 56 | + configured GitHub identity cannot read collaborators, the refresh is | | 57 | + unavailable rather than falling back to author-association metadata. | 46 | 58 | - A complete successful snapshot includes repository identity and a retrieval | 47 | 59 | timestamp. A subsequent retrieval failure returns an explicit stale snapshot | 48 | 60 | only when a previously validated snapshot exists; otherwise it is unavailable. | 49 | | -- Malformed fields, an exceeded issue/page limit, an unknown author association, | 50 | | - or an incomplete response fail the entire refresh closed. Partial records are | 51 | | - never ranked as if missing values were zero. | | 61 | +- Malformed fields, an exceeded issue or collaborator limit, an unknown author | | 62 | + association, or an incomplete response fail the entire refresh closed. | | 63 | + Partial records are never ranked as if missing values were zero. | 52 | 64 |
| 53 | 65 | ## Ownership and staged delivery | 54 | 66 |
| |
Collapse file Copy file name to clipboardExpand all lines: github/issues.ts +51-2Lines changed: 51 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -5,6 +5,7 @@ const runFile = promisify(execFile); | 5 | 5 | const PAGE_SIZE = 100; | 6 | 6 | const MAX_PAGES = 10; | 7 | 7 | const MAX_ISSUES = PAGE_SIZE * MAX_PAGES; | | 8 | +const MAX_COLLABORATORS = PAGE_SIZE * MAX_PAGES; | 8 | 9 | const MAX_BODY_LENGTH = 65_536; | 9 | 10 | // Covers one bounded 100-record page, including JSON-escaped bodies, labels and response overhead. | 10 | 11 | export const ISSUE_PAGE_MAX_BYTES = 64 * 1024 * 1024; | @@ -24,6 +25,7 @@ export interface RepositoryIssue { | 24 | 25 | readonly comments: number; | 25 | 26 | readonly positiveReactions: number; | 26 | 27 | readonly labels: readonly string[]; | | 28 | + readonly authorLogin: string | null; | 27 | 29 | readonly authorAssociation: IssueAuthorAssociation; | 28 | 30 | readonly trust: 'trusted' | 'requires-approval'; | 29 | 31 | } | @@ -62,6 +64,12 @@ function count(value: unknown, field: string): number { | 62 | 64 | return value as number; | 63 | 65 | } | 64 | 66 |
| | 67 | +function login(value: unknown, context: 'issue author' | 'collaborator'): string { | | 68 | + if (typeof value !== 'string' || !value || value.length > 100 || /[\s\u0000-\u001f\u007f]/u.test(value)) | | 69 | + throw new Error(`GitHub returned an invalid ${context} login.`); | | 70 | + return value; | | 71 | +} | | 72 | + | 65 | 73 | function repositoryName(value: string): boolean { | 66 | 74 | if (value.length > 201 || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) return false; | 67 | 75 | return value.split('/').every(part => part !== '.' && part !== '..' && part.length <= 100); | @@ -113,6 +121,10 @@ function normalizeIssue(repository: string, value: unknown): RepositoryIssue | n | 113 | 121 | if (typeof authorAssociation !== 'string' || !associations.has(authorAssociation as IssueAuthorAssociation)) | 114 | 122 | throw new Error('GitHub returned an unknown issue author association.'); | 115 | 123 | const association = authorAssociation as IssueAuthorAssociation; | | 124 | + if (!Object.hasOwn(issue, 'user')) throw new Error('GitHub omitted the issue author.'); | | 125 | + const authorLogin = issue.user === null | | 126 | + ? null | | 127 | + : login(object(issue.user, 'GitHub returned an invalid issue author.').login, 'issue author'); | 116 | 128 | const title = boundedString(issue.title, 'title', 4096); | 117 | 129 | if (!title.trim()) throw new Error('GitHub returned an empty issue title.'); | 118 | 130 | return { | @@ -126,8 +138,9 @@ function normalizeIssue(repository: string, value: unknown): RepositoryIssue | n | 126 | 138 | comments: count(issue.comments, 'comment count'), | 127 | 139 | positiveReactions, | 128 | 140 | labels, | | 141 | + authorLogin, | 129 | 142 | authorAssociation: association, | 130 | | - trust: ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association) ? 'trusted' : 'requires-approval', | | 143 | + trust: 'requires-approval', | 131 | 144 | }; | 132 | 145 | } | 133 | 146 |
| @@ -147,7 +160,7 @@ export class GhIssueGateway implements IssueGateway { | 147 | 160 | this.now = now; | 148 | 161 | } | 149 | 162 |
| 150 | | - async #load(signal: AbortSignal): Promise<RepositoryIssue[]> { | | 163 | + async #loadIssues(signal: AbortSignal): Promise<RepositoryIssue[]> { | 151 | 164 | const issues: RepositoryIssue[] = []; | 152 | 165 | const numbers = new Set<number>(); | 153 | 166 | for (let page = 1; page <= MAX_PAGES; page++) { | @@ -172,6 +185,42 @@ export class GhIssueGateway implements IssueGateway { | 172 | 185 | throw new Error(`Issue retrieval exceeded the ${MAX_ISSUES}-record safety limit.`); | 173 | 186 | } | 174 | 187 |
| | 188 | + async #loadCollaborators(signal: AbortSignal): Promise<Set<string>> { | | 189 | + const collaborators = new Set<string>(); | | 190 | + for (let page = 1; page <= MAX_PAGES; page++) { | | 191 | + const output = await this.run([ | | 192 | + 'api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json', | | 193 | + `repos/${this.repository}/collaborators`, '-f', 'affiliation=all', | | 194 | + '-f', `per_page=${PAGE_SIZE}`, '-f', `page=${page}`, | | 195 | + ], { signal }); | | 196 | + let decoded: unknown; | | 197 | + try { decoded = JSON.parse(output); } | | 198 | + catch { throw new Error('GitHub returned invalid collaborator JSON.'); } | | 199 | + if (!Array.isArray(decoded)) throw new Error('GitHub returned an invalid collaborator page.'); | | 200 | + if (decoded.length > PAGE_SIZE) throw new Error('GitHub returned an oversized collaborator page.'); | | 201 | + for (const value of decoded) { | | 202 | + const author = object(value, 'GitHub returned an invalid collaborator.'); | | 203 | + const key = login(author.login, 'collaborator').toLocaleLowerCase('en-US'); | | 204 | + if (collaborators.has(key)) throw new Error('GitHub returned a duplicate collaborator.'); | | 205 | + collaborators.add(key); | | 206 | + } | | 207 | + if (decoded.length < PAGE_SIZE) return collaborators; | | 208 | + } | | 209 | + throw new Error(`Collaborator retrieval exceeded the ${MAX_COLLABORATORS}-record safety limit.`); | | 210 | + } | | 211 | + | | 212 | + async #load(signal: AbortSignal): Promise<RepositoryIssue[]> { | | 213 | + const issues = await this.#loadIssues(signal); | | 214 | + signal.throwIfAborted(); | | 215 | + const collaborators = await this.#loadCollaborators(signal); | | 216 | + return issues.map(issue => ({ | | 217 | + ...issue, | | 218 | + trust: issue.authorLogin !== null && collaborators.has(issue.authorLogin.toLocaleLowerCase('en-US')) | | 219 | + ? 'trusted' | | 220 | + : 'requires-approval', | | 221 | + })); | | 222 | + } | | 223 | + | 175 | 224 | async fetch(options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<IssueSnapshot> { | 176 | 225 | const timeoutMs = options.timeoutMs ?? 12_000; | 177 | 226 | if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000) throw new Error('Invalid issue retrieval timeout.'); | |
Collapse file Copy file name to clipboardExpand all lines: test/issue-ranking.test.ts +1Lines changed: 1 addition & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -13,6 +13,7 @@ const issue = (number: number, overrides: Partial<RepositoryIssue> = {}): Reposi | 13 | 13 | comments: 0, | 14 | 14 | positiveReactions: 0, | 15 | 15 | labels: [], | | 16 | + authorLogin: 'member', | 16 | 17 | authorAssociation: 'MEMBER', | 17 | 18 | trust: 'trusted', | 18 | 19 | ...overrides, | |
Collapse file Copy file name to clipboardExpand all lines: test/issues.test.ts +89-6Lines changed: 89 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -10,13 +10,38 @@ const rawIssue = (overrides: Record<string, unknown> = {}) => ({ | 10 | 10 | created_at: '2026-01-01T00:00:00Z', | 11 | 11 | updated_at: '2026-01-02T00:00:00Z', | 12 | 12 | comments: 3, | | 13 | + user: { login: 'member' }, | 13 | 14 | author_association: 'MEMBER', | 14 | 15 | labels: [{ name: 'bug' }, { name: 'P1' }], | 15 | 16 | reactions: { '+1': 2, heart: 1, hooray: 0, rocket: 1 }, | 16 | 17 | ...overrides, | 17 | 18 | }); | 18 | 19 |
| | 20 | +const isCollaboratorRequest = (args: readonly string[]) => | | 21 | + args.some(argument => argument.toLocaleLowerCase('en-US').endsWith('/collaborators')); | | 22 | +const responses = (issues: readonly unknown[], collaborators: readonly string[] = ['MEMBER']) => | | 23 | + async (args: readonly string[]) => JSON.stringify(isCollaboratorRequest(args) | | 24 | + ? collaborators.map(login => ({ login })) | | 25 | + : issues); | | 26 | + | 19 | 27 | describe('GitHub issue retrieval', () => { | | 28 | + it.each([ | | 29 | + 'OWNER', | | 30 | + 'MEMBER', | | 31 | + 'COLLABORATOR', | | 32 | + 'CONTRIBUTOR', | | 33 | + 'FIRST_TIMER', | | 34 | + 'FIRST_TIME_CONTRIBUTOR', | | 35 | + 'MANNEQUIN', | | 36 | + 'NONE', | | 37 | + ] as const)('classifies the intermediate %s association using current repository membership', async (authorAssociation) => { | | 38 | + const issue = rawIssue({ author_association: authorAssociation }); | | 39 | + const trusted = await new GhIssueGateway('owner/repo', responses([issue])).fetch(); | | 40 | + const outside = await new GhIssueGateway('owner/repo', responses([issue], [])).fetch(); | | 41 | + expect(trusted.issues[0]).toMatchObject({ authorAssociation, authorLogin: 'member', trust: 'trusted' }); | | 42 | + expect(outside.issues[0]).toMatchObject({ authorAssociation, authorLogin: 'member', trust: 'requires-approval' }); | | 43 | + }); | | 44 | + | 20 | 45 | it.each(['./repo', '../repo', 'owner/..'])('rejects unsafe repository identity %s', repository => { | 21 | 46 | expect(() => new GhIssueGateway(repository)).toThrow('GitHub repository'); | 22 | 47 | }); | @@ -25,12 +50,15 @@ describe('GitHub issue retrieval', () => { | 25 | 50 | const calls: readonly string[][] = []; | 26 | 51 | const run = vi.fn(async (args: readonly string[]) => { | 27 | 52 | (calls as string[][]).push([...args]); | 28 | | - return JSON.stringify([rawIssue()]); | | 53 | + return responses([rawIssue()])(args); | 29 | 54 | }); | 30 | 55 | const snapshot = await new GhIssueGateway('owner/repo', run, () => new Date('2026-02-01T00:00:00Z')).fetch(); | 31 | 56 | expect(calls).toEqual([[ | 32 | 57 | 'api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json', | 33 | 58 | 'repos/owner/repo/issues', '-f', 'state=open', '-f', 'per_page=100', '-f', 'page=1', | | 59 | + ], [ | | 60 | + 'api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json', | | 61 | + 'repos/owner/repo/collaborators', '-f', 'affiliation=all', '-f', 'per_page=100', '-f', 'page=1', | 34 | 62 | ]]); | 35 | 63 | expect(snapshot).toEqual({ | 36 | 64 | repository: 'owner/repo', | @@ -39,23 +67,23 @@ describe('GitHub issue retrieval', () => { | 39 | 67 | repository: 'owner/repo', number: 7, title: 'Fix retries', body: 'Keep issue text as data.', | 40 | 68 | url: 'https://github.com/owner/repo/issues/7', createdAt: '2026-01-01T00:00:00Z', | 41 | 69 | updatedAt: '2026-01-02T00:00:00Z', comments: 3, positiveReactions: 4, | 42 | | - labels: ['bug', 'P1'], authorAssociation: 'MEMBER', trust: 'trusted', | | 70 | + labels: ['bug', 'P1'], authorLogin: 'member', authorAssociation: 'MEMBER', trust: 'trusted', | 43 | 71 | }], | 44 | 72 | }); | 45 | 73 | }); | 46 | 74 |
| 47 | 75 | it('excludes pull requests and requires approval for outside authors', async () => { | 48 | | - const run = vi.fn(async () => JSON.stringify([ | | 76 | + const run = vi.fn(responses([ | 49 | 77 | rawIssue({ pull_request: { url: 'https://api.github.com/repos/owner/repo/pulls/7' } }), | 50 | 78 | rawIssue({ number: 8, html_url: 'https://github.com/owner/repo/issues/8', author_association: 'CONTRIBUTOR' }), | 51 | | - ])); | | 79 | + ], [])); | 52 | 80 | const snapshot = await new GhIssueGateway('owner/repo', run).fetch(); | 53 | 81 | expect(snapshot.issues).toHaveLength(1); | 54 | 82 | expect(snapshot.issues[0]).toMatchObject({ number: 8, trust: 'requires-approval' }); | 55 | 83 | }); | 56 | 84 |
| 57 | 85 | it('accepts canonical URL casing without relaxing repository identity or URL shape', async () => { | 58 | | - const run = vi.fn(async () => JSON.stringify([ | | 86 | + const run = vi.fn(responses([ | 59 | 87 | rawIssue(), | 60 | 88 | rawIssue({ | 61 | 89 | number: 8, | @@ -68,13 +96,36 @@ describe('GitHub issue retrieval', () => { | 68 | 96 | }); | 69 | 97 |
| 70 | 98 | it('accepts the maximum bounded issue body', async () => { | 71 | | - const gateway = new GhIssueGateway('owner/repo', async () => JSON.stringify([ | | 99 | + const gateway = new GhIssueGateway('owner/repo', responses([ | 72 | 100 | rawIssue({ body: 'x'.repeat(65_536) }), | 73 | 101 | ])); | 74 | 102 | const snapshot = await gateway.fetch(); | 75 | 103 | expect(snapshot.issues[0]?.body).toHaveLength(65_536); | 76 | 104 | }); | 77 | 105 |
| | 106 | + it('requires approval when GitHub explicitly reports a deleted author', async () => { | | 107 | + const snapshot = await new GhIssueGateway('owner/repo', responses([ | | 108 | + rawIssue({ user: null, author_association: 'OWNER' }), | | 109 | + ])).fetch(); | | 110 | + expect(snapshot.issues[0]).toMatchObject({ authorLogin: null, trust: 'requires-approval' }); | | 111 | + }); | | 112 | + | | 113 | + it('fails closed on collaborator-access failure even when every author is deleted', async () => { | | 114 | + const gateway = new GhIssueGateway('owner/repo', async args => { | | 115 | + if (isCollaboratorRequest(args)) throw new Error('Collaborators unavailable.'); | | 116 | + return JSON.stringify([rawIssue({ user: null, author_association: 'OWNER' })]); | | 117 | + }); | | 118 | + await expect(gateway.fetch()).rejects.toThrow('Collaborators unavailable.'); | | 119 | + }); | | 120 | + | | 121 | + it('fails closed on collaborator-access failure even when no issues are open', async () => { | | 122 | + const gateway = new GhIssueGateway('owner/repo', async args => { | | 123 | + if (isCollaboratorRequest(args)) throw new Error('Collaborators unavailable.'); | | 124 | + return '[]'; | | 125 | + }); | | 126 | + await expect(gateway.fetch()).rejects.toThrow('Collaborators unavailable.'); | | 127 | + }); | | 128 | + | 78 | 129 | it('budgets for a maximum page of JSON-escaped control-character bodies', () => { | 79 | 130 | const body = '\0'.repeat(65_536); | 80 | 131 | const page = Array.from({ length: 100 }, (_, index) => rawIssue({ | @@ -92,6 +143,8 @@ describe('GitHub issue retrieval', () => { | 92 | 143 | ['state', { state: 'closed' }], | 93 | 144 | ['pull request marker', { pull_request: {} }], | 94 | 145 | ['author association', { author_association: 'UNKNOWN' }], | | 146 | + ['author object', { user: 42 }], | | 147 | + ['author login', { user: { login: '' } }], | 95 | 148 | ['title', { title: ' ' }], | 96 | 149 | ['body length', { body: 'x'.repeat(65_537) }], | 97 | 150 | ['comments', { comments: -1 }], | @@ -104,6 +157,36 @@ describe('GitHub issue retrieval', () => { | 104 | 157 | await expect(gateway.fetch()).rejects.toThrow(/GitHub returned/); | 105 | 158 | }); | 106 | 159 |
| | 160 | + it('fails closed when collaborator pagination reaches its bounded limit', async () => { | | 161 | + const gateway = new GhIssueGateway('owner/repo', async args => { | | 162 | + if (!isCollaboratorRequest(args)) return JSON.stringify([rawIssue()]); | | 163 | + const page = Number(args.at(-1)?.split('=')[1]); | | 164 | + return JSON.stringify(Array.from({ length: 100 }, (_, index) => ({ | | 165 | + login: `member-${(page - 1) * 100 + index + 1}`, | | 166 | + }))); | | 167 | + }); | | 168 | + await expect(gateway.fetch()).rejects.toThrow('Collaborator retrieval exceeded the 1000-record safety limit'); | | 169 | + }); | | 170 | + | | 171 | + it('distinguishes an omitted author from an explicitly deleted author', async () => { | | 172 | + const issue: Record<string, unknown> = rawIssue(); | | 173 | + delete issue.user; | | 174 | + await expect(new GhIssueGateway('owner/repo', responses([issue])).fetch()) | | 175 | + .rejects.toThrow('omitted the issue author'); | | 176 | + }); | | 177 | + | | 178 | + it.each([ | | 179 | + ['page shape', {}], | | 180 | + ['record shape', [null]], | | 181 | + ['login', [{ login: '' }]], | | 182 | + ['duplicate', [{ login: 'member' }, { login: 'MEMBER' }]], | | 183 | + ])('fails closed on invalid collaborator %s', async (_label, collaboratorPage) => { | | 184 | + const gateway = new GhIssueGateway('owner/repo', async args => JSON.stringify( | | 185 | + isCollaboratorRequest(args) ? collaboratorPage : [rawIssue()], | | 186 | + )); | | 187 | + await expect(gateway.fetch()).rejects.toThrow(/GitHub returned/); | | 188 | + }); | | 189 | + | 107 | 190 | it('rejects duplicate records across pages', async () => { | 108 | 191 | let page = 0; | 109 | 192 | const gateway = new GhIssueGateway('owner/repo', async () => { | |
|