diff --git a/__tests__/integration/app.test.js b/__tests__/integration/app.test.js index 2b68486..c6556f7 100644 --- a/__tests__/integration/app.test.js +++ b/__tests__/integration/app.test.js @@ -252,6 +252,79 @@ describe('app', () => { _setConfigForTesting({}); }); + // ========================================================================= + // chatops_repo enforcement — slash commands only honoured in admin repo + // ========================================================================= + describe('issue_comment.created chatops_repo gate', () => { + function ctx(repoFullName, body) { + const [o, r] = repoFullName.split('/'); + const octokit = createMockOctokit(); + return { + id: 'delivery-chatops', + log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + octokit, + payload: { + comment: { body }, + repository: { full_name: repoFullName, name: r, owner: { login: o } }, + sender: { login: 'avrabe' }, + issue: { number: 1 } + } + }; + } + + it('honours commands from the designated chatops repo', async () => { + _setConfigForTesting({ + chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' }, + allowed_command_users: ['avrabe'] + }); + const { handlers } = setupApp(); + configureRepository.mockResolvedValue({ success: true }); + + await handlers['issue_comment.created'](ctx('pulseengine/temper-ops', '/configure-repo')); + + expect(configureRepository).toHaveBeenCalled(); + }); + + it('silently ignores slash commands from any other repo', async () => { + _setConfigForTesting({ + chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' }, + allowed_command_users: ['avrabe'] + }); + const { handlers } = setupApp(); + + const c = ctx('pulseengine/some-public-repo', '/configure-repo'); + await handlers['issue_comment.created'](c); + + expect(configureRepository).not.toHaveBeenCalled(); + // Critically: no comment posted back. Silence in public repos. + expect(c.octokit.issues.createComment).not.toHaveBeenCalled(); + }); + + it('does not gate non-command comments (only / triggers are gated)', async () => { + _setConfigForTesting({ + chatops_repo: { enabled: true, repo: 'pulseengine/temper-ops' } + }); + const { handlers } = setupApp(); + + const c = ctx('pulseengine/some-public-repo', 'just chatting, no command'); + await handlers['issue_comment.created'](c); + // Plain comments from any repo are still silently dropped (no command + // matched), and no error is raised. + expect(c.octokit.issues.createComment).not.toHaveBeenCalled(); + }); + + it('falls back to honouring all repos when chatops_repo is disabled (legacy default)', async () => { + _setConfigForTesting({ + allowed_command_users: ['avrabe'] + }); + const { handlers } = setupApp(); + configureRepository.mockResolvedValue({ success: true }); + + await handlers['issue_comment.created'](ctx('pulseengine/anywhere', '/configure-repo')); + expect(configureRepository).toHaveBeenCalled(); + }); + }); + // ========================================================================= // issues.opened — controller repo / issue-driven provisioning // ========================================================================= diff --git a/config.yml b/config.yml index d201e50..d6da25f 100644 --- a/config.yml +++ b/config.yml @@ -63,6 +63,16 @@ controller_repo: # Reaction approvers must add to authorise provisioning. approval_reaction: "+1" +# ChatOps surface restriction. When enabled, ALL slash commands +# (/configure-repo, /sync-all-repos, /review-pr, etc.) are silently ignored +# from any repo other than the designated `repo`. Combined with making +# `repo` private, the entire command-and-control conversation stays out of +# public view. Bot-initiated configuration PRs in public repos are still +# public — this only hides the *triggers*. +chatops_repo: + enabled: false + repo: pulseengine/temper-ops + # Configuration changes (dependabot.yml, templates, CODEOWNERS) are applied via # pull request rather than direct push. Required because branch protection # (PR #19) blocks direct commits to default branches. diff --git a/src/app.js b/src/app.js index 6d2984f..f98074b 100644 --- a/src/app.js +++ b/src/app.js @@ -2,7 +2,7 @@ import path from 'path'; import fs from 'node:fs'; import { fileURLToPath } from 'url'; import { createDashboardHandler, DEPLOY_SHA } from './dashboard.js'; -import { getConfig, getControllerRepoConfig } from './config.js'; +import { getConfig, getControllerRepoConfig, getChatopsRepoConfig } from './config.js'; import { getLogger, setLogger } from './logger.js'; import { configureRepository } from './repository.js'; import { @@ -310,6 +310,20 @@ function registerApp(app, options = {}) { const commandBody = comment.body.trim(); const owner = repository.owner.login; const repo = repository.name; + + // ChatOps surface restriction: when enabled, slash commands are only + // honoured in the designated admin repo. Comments from any other repo + // are silently ignored — no log, no reply, no bot footprint in public + // repos. The actual repo configuration changes still happen in their + // target repos; this only restricts where the *triggers* can come from. + const chatopsCfg = getChatopsRepoConfig(); + if (chatopsCfg?.enabled && chatopsCfg?.repo && commandBody.startsWith('/')) { + const fullName = `${owner}/${repo}`; + if (fullName !== chatopsCfg.repo) { + if (deliveryId) markProcessed(deliveryId); + return; + } + } const issueNumber = context.payload.issue.number; const senderLogin = sender.login; diff --git a/src/config.js b/src/config.js index df2dd39..f562382 100644 --- a/src/config.js +++ b/src/config.js @@ -160,6 +160,10 @@ export function getControllerRepoConfig() { return config?.controller_repo || { enabled: false }; } +export function getChatopsRepoConfig() { + return config?.chatops_repo || { enabled: false }; +} + export function getRequiredSignaturesFlag(protectionConfig = {}) { if (typeof protectionConfig.require_signed_commits === 'boolean') { return protectionConfig.require_signed_commits; diff --git a/src/schema.js b/src/schema.js index c2a1d2b..5d38aeb 100644 --- a/src/schema.js +++ b/src/schema.js @@ -171,6 +171,20 @@ export function validateConfig(config) { } } + if (config.chatops_repo !== undefined) { + const cr = config.chatops_repo; + if (typeof cr !== 'object' || cr === null) { + errors.push('chatops_repo must be an object'); + } else { + if (cr.enabled !== undefined && typeof cr.enabled !== 'boolean') { + errors.push('chatops_repo.enabled must be a boolean'); + } + if (cr.enabled && (typeof cr.repo !== 'string' || !cr.repo.includes('/'))) { + errors.push('chatops_repo.repo must be "owner/repo" when enabled'); + } + } + } + if (config.rivet_oracle !== undefined) { const ro = config.rivet_oracle; if (typeof ro !== 'object' || ro === null) {