From 474899badc151a975b6e2bd300611316c3cf66c0 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 28 Apr 2026 19:13:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20chatops=5Frepo=20restriction=20?= =?UTF-8?q?=E2=80=94=20keep=20slash=20commands=20off=20public=20repos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why ChatOps commands (/configure-repo, /sync-all-repos, /review-pr, etc.) are currently honoured from any repo where Temper is installed. Even with allowed_command_users restricting *who* can trigger them, the bot's "Working on it…" replies and any error responses are public — sitting in whichever public repo the maintainer happened to comment in. For an org running an org-wide config sweep (especially the upcoming /sync-all-repos that migrates every repo to Repository Rulesets + applies config changes via PR), the user wants the trigger and the bot's response trail to stay in a private admin repo. ## What Opt-in `chatops_repo:` config section: chatops_repo: enabled: false repo: pulseengine/temper-ops When `enabled: true`, the issue_comment.created handler silently ignores any comment starting with `/` UNLESS it's posted in `repo`. The check fires *before* command extraction, so: - no log line emitted - no comment posted back - no public footprint at all Non-command comments (no leading `/`) are unaffected — handler proceeds through the existing no-op path. Commands posted in the designated repo work exactly as before. ## Operational pattern this unlocks 1. Create `pulseengine/temper-ops` as a **private** repo 2. Install Temper on it 3. Set `chatops_repo.enabled: true` and `repo: pulseengine/temper-ops` in `config.local.yml` 4. Trigger /sync-all-repos, /configure-repo, etc. from there 5. The bot's "Working on it…" + result replies all land in the private repo. Configuration PRs the bot opens against public repos are still public — that's the intentional asymmetry: trigger private, application public, audit trail intact. ## Test plan - [x] All 792 tests pass (was 788; +4 covering: command honoured from admin repo, command silently ignored from public repo, non-command comments unaffected, legacy default still honours every repo) - [x] eslint clean - [ ] After deploy: enable chatops_repo, comment /configure-repo on any public pulseengine repo — should be a complete no-op (no working comment, no error). Comment from temper-ops — should work normally. ## Risk & rollout - Risk: low. Opt-in via config; default behaviour unchanged. - Rollout: self-update on merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/integration/app.test.js | 73 +++++++++++++++++++++++++++++++ config.yml | 10 +++++ src/app.js | 16 ++++++- src/config.js | 4 ++ src/schema.js | 14 ++++++ 5 files changed, 116 insertions(+), 1 deletion(-) 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) {