feat: add PreToolUse hook to gate GroupMe write operations #163

Merged
forgejo_admin merged 2 commits from 160-block-groupme-send-hook into main 2026-03-26 04:09:12 +00:00
Contributor

Summary

Adds a PreToolUse hook that requires explicit user approval before any GroupMe write operation (send_message, add_member, remove_member). Triggered by the 2026-03-25 data exposure incident where a forked session sent contract data with parent names and emails to the wrong GroupMe group.

Changes

  • hooks/block-groupme-send.sh -- NEW. PreToolUse hook following the block-mcp-merge.sh pattern. Extracts group_name from tool_input, formats a tool-specific summary, returns permissionDecision: "ask". Each write tool gets a distinct prompt:
    • send_message: "Send to {group_name}: {text preview}"
    • add_member: "Add {nickname} to {group_name}"
    • remove_member: "Remove member {membership_id} from {group_name}"
  • settings.json -- Added PreToolUse entry matching mcp__groupme__send_message|mcp__groupme__add_member|mcp__groupme__remove_member
  • tests/test_block_groupme_send.sh -- NEW. 18 assertions across 6 test cases covering all 3 write tool shapes, unknown tool passthrough, text truncation (80 char limit), and missing field defaults.

Test Plan

  • bash tests/test_block_groupme_send.sh -- 18/18 pass
  • Read-only tools (list_groups, list_members, get_group) are excluded from the hook matcher -- no approval friction for reads
  • settings.json validated as valid JSON

Review Checklist

  • Hook follows block-mcp-merge.sh pattern exactly
  • Only write tools gated (send_message, add_member, remove_member)
  • Read-only tools excluded (list_groups, list_members, get_group)
  • Tool-specific summary in permission prompt for each write tool
  • Text preview truncated to 80 chars for send_message
  • Graceful defaults when fields are missing
  • All tests pass (18/18)
  • settings.json valid JSON
  • No unrelated changes
  • Closes #160
  • Deploy order: groupme-mcp (name-based resolution) deploys first, then this hook
## Summary Adds a PreToolUse hook that requires explicit user approval before any GroupMe write operation (`send_message`, `add_member`, `remove_member`). Triggered by the 2026-03-25 data exposure incident where a forked session sent contract data with parent names and emails to the wrong GroupMe group. ## Changes - `hooks/block-groupme-send.sh` -- NEW. PreToolUse hook following the `block-mcp-merge.sh` pattern. Extracts `group_name` from `tool_input`, formats a tool-specific summary, returns `permissionDecision: "ask"`. Each write tool gets a distinct prompt: - send_message: "Send to {group_name}: {text preview}" - add_member: "Add {nickname} to {group_name}" - remove_member: "Remove member {membership_id} from {group_name}" - `settings.json` -- Added PreToolUse entry matching `mcp__groupme__send_message|mcp__groupme__add_member|mcp__groupme__remove_member` - `tests/test_block_groupme_send.sh` -- NEW. 18 assertions across 6 test cases covering all 3 write tool shapes, unknown tool passthrough, text truncation (80 char limit), and missing field defaults. ## Test Plan - `bash tests/test_block_groupme_send.sh` -- 18/18 pass - Read-only tools (`list_groups`, `list_members`, `get_group`) are excluded from the hook matcher -- no approval friction for reads - `settings.json` validated as valid JSON ## Review Checklist - [x] Hook follows `block-mcp-merge.sh` pattern exactly - [x] Only write tools gated (send_message, add_member, remove_member) - [x] Read-only tools excluded (list_groups, list_members, get_group) - [x] Tool-specific summary in permission prompt for each write tool - [x] Text preview truncated to 80 chars for send_message - [x] Graceful defaults when fields are missing - [x] All tests pass (18/18) - [x] settings.json valid JSON - [x] No unrelated changes ## Related - Closes #160 - Deploy order: groupme-mcp (name-based resolution) deploys first, then this hook
Prevent accidental GroupMe sends by requiring explicit user approval
before send_message, add_member, and remove_member. Triggered by
2026-03-25 data exposure incident where contract data went to the
wrong group.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Contributor

QA Review -- APPROVED

Verdict: approved

Review Summary

All 3 files are clean and correct. No issues found.

hooks/block-groupme-send.sh

  • Follows block-mcp-merge.sh pattern exactly: reads stdin, extracts fields with jq, emits permissionDecision: "ask"
  • Case statement handles all 3 write tools with tool-specific summaries
  • Unknown tools fall through to exit 0 (no interference with read-only tools)
  • Text preview truncated to 80 chars via head -c 80
  • Missing fields default to "unknown" via jq // operator

settings.json

  • Matcher covers exactly the 3 write tools: send_message|add_member|remove_member
  • Read-only tools (list_groups, list_members, get_group) correctly excluded
  • Valid JSON confirmed
  • Placement is consistent with other MCP PreToolUse entries

tests/test_block_groupme_send.sh

  • 18 assertions across 6 test cases
  • Covers all 3 write tool shapes, unknown tool passthrough, text truncation, missing field defaults
  • Clean test harness with assert_contains, assert_ask, assert_empty helpers
  • All 18/18 pass
## QA Review -- APPROVED **Verdict: approved** ### Review Summary All 3 files are clean and correct. No issues found. **hooks/block-groupme-send.sh** - Follows `block-mcp-merge.sh` pattern exactly: reads stdin, extracts fields with jq, emits `permissionDecision: "ask"` - Case statement handles all 3 write tools with tool-specific summaries - Unknown tools fall through to `exit 0` (no interference with read-only tools) - Text preview truncated to 80 chars via `head -c 80` - Missing fields default to "unknown" via jq `//` operator **settings.json** - Matcher covers exactly the 3 write tools: `send_message|add_member|remove_member` - Read-only tools (`list_groups`, `list_members`, `get_group`) correctly excluded - Valid JSON confirmed - Placement is consistent with other MCP PreToolUse entries **tests/test_block_groupme_send.sh** - 18 assertions across 6 test cases - Covers all 3 write tool shapes, unknown tool passthrough, text truncation, missing field defaults - Clean test harness with `assert_contains`, `assert_ask`, `assert_empty` helpers - All 18/18 pass
Author
Contributor

PR #163 Review

DOMAIN REVIEW

Tech stack: Bash hooks for Claude Code PreToolUse system + JSON (settings.json). Domain expertise applied: shell scripting patterns, Claude Code hook contract, jq usage, MCP tool schema validation.

Hook script (hooks/block-groupme-send.sh -- 37 lines): Clean, minimal, follows the block-mcp-merge.sh pattern. INPUT=$(cat) for stdin, jq -r for field extraction, case statement for tool routing, jq -n for structured JSON output. Default * case exits 0 (passthrough for unmatched tools). Text truncation via head -c 80.

Settings.json: New PreToolUse entry with pipe-separated matcher for 3 write tools. Correctly placed between the merge gate and the Task gate. JSON structure is valid.

Tests (tests/test_block_groupme_send.sh -- 144 lines): 18 assertions across 6 test cases. Covers all 3 write tool shapes, unknown tool passthrough, text truncation, and missing field defaults. Well-structured with assert_contains, assert_ask, and assert_empty helpers. This is the first test file in the repo -- establishes a tests/ directory convention.

BLOCKERS

1. Hook extracts group_name but MCP tools use group_id

The hook reads .tool_input.group_name on line 11, but all three GroupMe MCP tools (send_message, add_member, remove_member) use group_id as the parameter -- a numeric ID, not a human-readable name. Verified against the live MCP tool schemas:

  • send_message: {group_id: string, text: string} -- no group_name
  • add_member: {group_id: string, nickname: string, ...} -- no group_name
  • remove_member: {group_id: string, membership_id: string} -- no group_name

The PR description states "Deploy order: groupme-mcp (name-based resolution) deploys first, then this hook" -- meaning this hook targets a future MCP schema that does not yet exist. Until the MCP is updated, every permission prompt will show "Send to unknown: ..." because group_name will never be present in tool_input. The gate itself still fires (the ask decision still works), but the user gets zero useful context about which group is being targeted -- defeating the purpose of the incident-response hook.

This is a BLOCKER because:

  • The hook's raison d'etre is the 2026-03-25 incident where data went to the wrong group. Showing "unknown" in every prompt does not help the user catch wrong-group errors.
  • There is no guarantee the MCP change will deploy first. If this hook merges and deploys before the MCP update, the gate is live but useless for its stated purpose.
  • The tests validate against group_name inputs that will never arrive from the current MCP. All 18 assertions pass, but they test a fiction.

Resolution options:

  • (a) Add group_id extraction as a fallback: extract group_id from tool_input, display it in the prompt. At minimum the user sees the numeric ID and can cross-reference.
  • (b) Wait for the MCP update to land first, then submit this hook against the actual new schema.
  • (c) Extract both group_name (future) and group_id (current) with fallback: GROUP_NAME=$(echo "$INPUT" | jq -r '.tool_input.group_name // .tool_input.group_id // "unknown"'). This works today and tomorrow.

Option (c) is recommended -- it is forward-compatible and works immediately.

NITS

  1. No truncation indicator: head -c 80 truncates text silently. Adding ... when the text exceeds 80 chars would improve the prompt UX. Minor.

  2. create_group not gated: mcp__groupme__create_group is a write operation (creates a new group) but is excluded from the matcher. The incident was about wrong-group targeting, so create_group is arguably lower risk. But worth documenting the exclusion rationale.

  3. Test 5 fragile JSON construction: The long-text test (lines 112-124) builds JSON via string interpolation with escaped quotes rather than using jq -n. If LONG_TEXT contained quotes or backslashes, the test would break. Low risk since the test text is controlled, but a jq -n --arg approach would be more robust.

  4. First tests/ directory in repo: This PR establishes the convention. No issue, just noting it for process awareness -- future hooks should follow this pattern.

SOP COMPLIANCE

  • Branch named after issue: 160-block-groupme-send-hook references issue #160
  • PR body follows template: Summary, Changes, Test Plan, Review Checklist, Related sections present
  • Related references parent issue: "Closes #160"
  • Related references plan slug: No plan slug referenced. Issue #160 is titled as a hook+MCP fix, not tied to a plan. Acceptable if this is standalone board work.
  • Tests exist: 18 assertions, 6 test cases
  • No secrets committed: No credentials, tokens, or .env files
  • No scope creep: All 3 files are directly related to the GroupMe gate feature
  • Commit messages: PR title is descriptive

PROCESS OBSERVATIONS

DORA impact: This is a Change Failure Rate (CFR) mitigation -- a direct incident response. The hook reduces the probability of data exposure via GroupMe write operations. High-value, low-complexity change.

Deploy dependency risk: The PR explicitly states a deploy ordering dependency (MCP first, then hook). This creates a coordination requirement that is not enforced by any mechanism. If the hook deploys first, it works but provides degraded prompts. Recommend making the hook resilient to both schemas (option c above) to eliminate the ordering dependency entirely.

Test precedent: First test suite in the repo. Good signal for the project's maturity. Consider documenting the test convention (bash tests in tests/, naming pattern test_*.sh).

VERDICT: NOT APPROVED

One blocker: the hook extracts group_name from tool_input, but the current (and only deployed) MCP schema uses group_id. The hook will show "unknown" for every group until a not-yet-deployed MCP change lands. Fix with a group_name // group_id fallback to make the hook work today and forward-compatible with the planned MCP change.

## PR #163 Review ### DOMAIN REVIEW **Tech stack**: Bash hooks for Claude Code PreToolUse system + JSON (settings.json). Domain expertise applied: shell scripting patterns, Claude Code hook contract, jq usage, MCP tool schema validation. **Hook script** (`hooks/block-groupme-send.sh` -- 37 lines): Clean, minimal, follows the `block-mcp-merge.sh` pattern. `INPUT=$(cat)` for stdin, `jq -r` for field extraction, `case` statement for tool routing, `jq -n` for structured JSON output. Default `*` case exits 0 (passthrough for unmatched tools). Text truncation via `head -c 80`. **Settings.json**: New PreToolUse entry with pipe-separated matcher for 3 write tools. Correctly placed between the merge gate and the Task gate. JSON structure is valid. **Tests** (`tests/test_block_groupme_send.sh` -- 144 lines): 18 assertions across 6 test cases. Covers all 3 write tool shapes, unknown tool passthrough, text truncation, and missing field defaults. Well-structured with `assert_contains`, `assert_ask`, and `assert_empty` helpers. This is the first test file in the repo -- establishes a `tests/` directory convention. ### BLOCKERS **1. Hook extracts `group_name` but MCP tools use `group_id`** The hook reads `.tool_input.group_name` on line 11, but all three GroupMe MCP tools (`send_message`, `add_member`, `remove_member`) use `group_id` as the parameter -- a numeric ID, not a human-readable name. Verified against the live MCP tool schemas: - `send_message`: `{group_id: string, text: string}` -- no `group_name` - `add_member`: `{group_id: string, nickname: string, ...}` -- no `group_name` - `remove_member`: `{group_id: string, membership_id: string}` -- no `group_name` The PR description states "Deploy order: groupme-mcp (name-based resolution) deploys first, then this hook" -- meaning this hook targets a future MCP schema that does not yet exist. **Until the MCP is updated, every permission prompt will show "Send to unknown: ..." because `group_name` will never be present in tool_input.** The gate itself still fires (the `ask` decision still works), but the user gets zero useful context about which group is being targeted -- defeating the purpose of the incident-response hook. This is a BLOCKER because: - The hook's raison d'etre is the 2026-03-25 incident where data went to the wrong group. Showing "unknown" in every prompt does not help the user catch wrong-group errors. - There is no guarantee the MCP change will deploy first. If this hook merges and deploys before the MCP update, the gate is live but useless for its stated purpose. - The tests validate against `group_name` inputs that will never arrive from the current MCP. All 18 assertions pass, but they test a fiction. **Resolution options**: - (a) Add `group_id` extraction as a fallback: extract `group_id` from tool_input, display it in the prompt. At minimum the user sees the numeric ID and can cross-reference. - (b) Wait for the MCP update to land first, then submit this hook against the actual new schema. - (c) Extract both `group_name` (future) and `group_id` (current) with fallback: `GROUP_NAME=$(echo "$INPUT" | jq -r '.tool_input.group_name // .tool_input.group_id // "unknown"')`. This works today and tomorrow. Option (c) is recommended -- it is forward-compatible and works immediately. ### NITS 1. **No truncation indicator**: `head -c 80` truncates text silently. Adding `...` when the text exceeds 80 chars would improve the prompt UX. Minor. 2. **`create_group` not gated**: `mcp__groupme__create_group` is a write operation (creates a new group) but is excluded from the matcher. The incident was about wrong-group targeting, so `create_group` is arguably lower risk. But worth documenting the exclusion rationale. 3. **Test 5 fragile JSON construction**: The long-text test (lines 112-124) builds JSON via string interpolation with escaped quotes rather than using `jq -n`. If `LONG_TEXT` contained quotes or backslashes, the test would break. Low risk since the test text is controlled, but a `jq -n --arg` approach would be more robust. 4. **First `tests/` directory in repo**: This PR establishes the convention. No issue, just noting it for process awareness -- future hooks should follow this pattern. ### SOP COMPLIANCE - [x] Branch named after issue: `160-block-groupme-send-hook` references issue #160 - [x] PR body follows template: Summary, Changes, Test Plan, Review Checklist, Related sections present - [x] Related references parent issue: "Closes #160" - [ ] Related references plan slug: No plan slug referenced. Issue #160 is titled as a hook+MCP fix, not tied to a plan. Acceptable if this is standalone board work. - [x] Tests exist: 18 assertions, 6 test cases - [x] No secrets committed: No credentials, tokens, or .env files - [x] No scope creep: All 3 files are directly related to the GroupMe gate feature - [x] Commit messages: PR title is descriptive ### PROCESS OBSERVATIONS **DORA impact**: This is a Change Failure Rate (CFR) mitigation -- a direct incident response. The hook reduces the probability of data exposure via GroupMe write operations. High-value, low-complexity change. **Deploy dependency risk**: The PR explicitly states a deploy ordering dependency (MCP first, then hook). This creates a coordination requirement that is not enforced by any mechanism. If the hook deploys first, it works but provides degraded prompts. Recommend making the hook resilient to both schemas (option c above) to eliminate the ordering dependency entirely. **Test precedent**: First test suite in the repo. Good signal for the project's maturity. Consider documenting the test convention (bash tests in `tests/`, naming pattern `test_*.sh`). ### VERDICT: NOT APPROVED One blocker: the hook extracts `group_name` from tool_input, but the current (and only deployed) MCP schema uses `group_id`. The hook will show "unknown" for every group until a not-yet-deployed MCP change lands. Fix with a `group_name // group_id` fallback to make the hook work today and forward-compatible with the planned MCP change.
The jq extraction on line 11 only checked .tool_input.group_name,
which broke backward compatibility for callers that pass group_id
instead. Chain the fallback: group_name // group_id // "unknown".

Adds test case 6 covering the group_id-only input shape.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Contributor

QA blocker fixed in aa8f6d5:

Hook fix (hooks/block-groupme-send.sh line 11):

# Before — only checked group_name
GROUP_NAME=$(echo "$INPUT" | jq -r '.tool_input.group_name // "unknown"')

# After — falls back to group_id for backward compatibility
GROUP_NAME=$(echo "$INPUT" | jq -r '.tool_input.group_name // .tool_input.group_id // "unknown"')

Test added (tests/test_block_groupme_send.sh):

  • New Test 6 covers the group_id-only input shape (no group_name present)
  • Verifies the group_id value appears in the permission prompt reason
  • Existing Test 7 (formerly Test 6) still verifies the "unknown" default when neither field is present

All 21 tests pass.

QA blocker fixed in aa8f6d5: **Hook fix** (`hooks/block-groupme-send.sh` line 11): ```bash # Before — only checked group_name GROUP_NAME=$(echo "$INPUT" | jq -r '.tool_input.group_name // "unknown"') # After — falls back to group_id for backward compatibility GROUP_NAME=$(echo "$INPUT" | jq -r '.tool_input.group_name // .tool_input.group_id // "unknown"') ``` **Test added** (`tests/test_block_groupme_send.sh`): - New Test 6 covers the `group_id`-only input shape (no `group_name` present) - Verifies the group_id value appears in the permission prompt reason - Existing Test 7 (formerly Test 6) still verifies the `"unknown"` default when neither field is present All 21 tests pass.
Author
Contributor

PR #163 Review (Re-review)

DOMAIN REVIEW

Tech stack: Bash shell scripting, jq, Claude Code PreToolUse hooks.

Previous blocker resolution: The prior review flagged that the hook used group_name without a group_id fallback, meaning the gate would show "unknown" when the MCP server sends group_id instead of group_name. This is now resolved:

GROUP_NAME=$(echo "$INPUT" | jq -r '.tool_input.group_name // .tool_input.group_id // "unknown"')

The jq alternative operator chain group_name // group_id // "unknown" correctly handles all three cases: name-based resolution (new MCP), ID-based (backward compat), and missing fields (graceful default). Test 6 explicitly validates this path by sending only group_id and asserting it appears in the reason string.

Pattern conformance: Hook follows block-mcp-merge.sh exactly -- INPUT=$(cat), jq -r extraction with // defaults, jq -n --arg for safe output construction, exit 0 for unknown tools. No deviation from the established pattern.

Safety: All values interpolated via jq --arg (safe from injection). No eval, no command substitution of user input. No secrets or credentials.

Test coverage: 7 test cases with 21 assertions covering all 3 write tools, unknown tool passthrough, text truncation, group_id fallback, and missing fields. PR body says "18 assertions across 6 test cases" -- actual count is 21/7. Minor description mismatch (tests exceed what's claimed, which is fine).

BLOCKERS

None. The previous blocker (group_name without group_id fallback) is resolved.

NITS

  1. create_group not gated: The GroupMe MCP exposes mcp__groupme__create_group as a write operation, but it is not included in the matcher. Creating a group is lower-risk than sending messages to the wrong one (which caused the incident), but it is still a mutation. Consider adding it to the matcher or documenting why it is excluded.

  2. Text truncation is byte-level, not character-level: head -c 80 truncates at 80 bytes, which can split multi-byte UTF-8 characters. Since this only affects the human-readable permission prompt and GroupMe messages are typically ASCII-heavy, this is cosmetic at worst.

  3. No negative assertion in truncation test: Test 5 asserts the truncated prefix is present but does not assert the tail end of the long text is absent. An assert_not_contains for a string that appears only after byte 80 would strengthen the test.

  4. PR body test count mismatch: Body says "18 assertions across 6 test cases" but the actual file contains 21 assertions across 7 test cases. Should be updated for accuracy.

SOP COMPLIANCE

  • Branch named after issue (160-block-groupme-send-hook references #160)
  • PR body has Summary, Changes, Test Plan, Related sections
  • Related references parent issue (Closes #160)
  • Tests exist and cover happy path, edge cases, and error handling
  • No secrets, .env files, or credentials committed
  • No unrelated file changes (3 files, all on-topic)
  • Commit messages are descriptive

PROCESS OBSERVATIONS

Incident-driven hook -- fast response to the 2026-03-25 data exposure. The pattern is well-established (block-mcp-merge.sh) and this hook follows it faithfully. The deploy ordering note in the PR body (groupme-mcp name-based resolution deploys first, then this hook) shows awareness of the dependency chain.

DORA impact: This directly reduces Change Failure Rate by adding a human-in-the-loop gate for GroupMe write operations. The ask permission decision means no latency added to read-only workflows.

VERDICT: APPROVED

## PR #163 Review (Re-review) ### DOMAIN REVIEW **Tech stack**: Bash shell scripting, jq, Claude Code PreToolUse hooks. **Previous blocker resolution**: The prior review flagged that the hook used `group_name` without a `group_id` fallback, meaning the gate would show "unknown" when the MCP server sends `group_id` instead of `group_name`. This is now resolved: ```bash GROUP_NAME=$(echo "$INPUT" | jq -r '.tool_input.group_name // .tool_input.group_id // "unknown"') ``` The jq alternative operator chain `group_name // group_id // "unknown"` correctly handles all three cases: name-based resolution (new MCP), ID-based (backward compat), and missing fields (graceful default). Test 6 explicitly validates this path by sending only `group_id` and asserting it appears in the reason string. **Pattern conformance**: Hook follows `block-mcp-merge.sh` exactly -- `INPUT=$(cat)`, `jq -r` extraction with `//` defaults, `jq -n --arg` for safe output construction, `exit 0` for unknown tools. No deviation from the established pattern. **Safety**: All values interpolated via jq `--arg` (safe from injection). No `eval`, no command substitution of user input. No secrets or credentials. **Test coverage**: 7 test cases with 21 assertions covering all 3 write tools, unknown tool passthrough, text truncation, group_id fallback, and missing fields. PR body says "18 assertions across 6 test cases" -- actual count is 21/7. Minor description mismatch (tests exceed what's claimed, which is fine). ### BLOCKERS None. The previous blocker (group_name without group_id fallback) is resolved. ### NITS 1. **`create_group` not gated**: The GroupMe MCP exposes `mcp__groupme__create_group` as a write operation, but it is not included in the matcher. Creating a group is lower-risk than sending messages to the wrong one (which caused the incident), but it is still a mutation. Consider adding it to the matcher or documenting why it is excluded. 2. **Text truncation is byte-level, not character-level**: `head -c 80` truncates at 80 bytes, which can split multi-byte UTF-8 characters. Since this only affects the human-readable permission prompt and GroupMe messages are typically ASCII-heavy, this is cosmetic at worst. 3. **No negative assertion in truncation test**: Test 5 asserts the truncated prefix is present but does not assert the tail end of the long text is absent. An `assert_not_contains` for a string that appears only after byte 80 would strengthen the test. 4. **PR body test count mismatch**: Body says "18 assertions across 6 test cases" but the actual file contains 21 assertions across 7 test cases. Should be updated for accuracy. ### SOP COMPLIANCE - [x] Branch named after issue (`160-block-groupme-send-hook` references #160) - [x] PR body has Summary, Changes, Test Plan, Related sections - [x] Related references parent issue (`Closes #160`) - [x] Tests exist and cover happy path, edge cases, and error handling - [x] No secrets, .env files, or credentials committed - [x] No unrelated file changes (3 files, all on-topic) - [x] Commit messages are descriptive ### PROCESS OBSERVATIONS Incident-driven hook -- fast response to the 2026-03-25 data exposure. The pattern is well-established (`block-mcp-merge.sh`) and this hook follows it faithfully. The deploy ordering note in the PR body (groupme-mcp name-based resolution deploys first, then this hook) shows awareness of the dependency chain. DORA impact: This directly reduces Change Failure Rate by adding a human-in-the-loop gate for GroupMe write operations. The `ask` permission decision means no latency added to read-only workflows. ### VERDICT: APPROVED
forgejo_admin deleted branch 160-block-groupme-send-hook 2026-03-26 04:09:12 +00:00
Sign in to join this conversation.
No description provided.