github: add read-only gh CLI tool

This commit is contained in:
2026-08-06 19:53:46 +08:00
parent c7878030f9
commit 0303bf0ac8
5 changed files with 370 additions and 12 deletions
+30 -12
View File
@@ -33,6 +33,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
### 工具调用 ### 工具调用
AI 可以自动调用多种工具来完成复杂任务: AI 可以自动调用多种工具来完成复杂任务:
- 网络搜索(需要配置 SearXNG) - 网络搜索(需要配置 SearXNG)
- GitHub 仓库、代码、Issue 和 PR 搜索(需要安装 `gh` 并配置只读 Token
- 代码执行(支持多种语言,需要配置 glot.io token - 代码执行(支持多种语言,需要配置 glot.io token
- 图像识别(需要配置视觉模型) - 图像识别(需要配置视觉模型)
- 推理思考(需要配置推理模型) - 推理思考(需要配置推理模型)
@@ -175,6 +176,10 @@ jinaApiKey: ''
jinaReaderUrl: 'https://r.jina.ai/' jinaReaderUrl: 'https://r.jina.ai/'
# SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回 # SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回
searXngUrl: '' searXngUrl: ''
# GitHub CLI 可执行文件路径;默认从系统 PATH 查找 gh
githubCliPath: 'gh'
# GitHub fine-grained 只读 Token;留空时禁用 GitHub 工具
githubToken: ''
# 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。 # 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。
glotToken: '' glotToken: ''
# 和风天气专属 API Host,可在和风天气控制台的设置页面查看 # 和风天气专属 API Host,可在和风天气控制台的设置页面查看
@@ -480,18 +485,31 @@ fallbackCooldownMinutes: 5
插件内置了丰富的工具供 AI 调用: 插件内置了丰富的工具供 AI 调用:
1. **WebSearch** - 使用 SearXNG 进行网络搜索 1. **WebSearch** - 使用 SearXNG 进行网络搜索
2. **RunCode** - 在 glot.io 上执行多种编程语言代码 2. **GithubAgent** - 使用认证的 `gh` 搜索和只读访问 GitHub 仓库、代码、Issue 与 PR
3. **VisualAgent** - 图像识别和理解 3. **RunCode** - 在 glot.io 上执行多种编程语言代码
4. **ReasoningAgent** - 深度思考和推理 4. **VisualAgent** - 图像识别和理解
5. **MemoryAppend/Replace** - 对话记忆管 5. **ReasoningAgent** - 深度思考和推
6. **LoadSkill/SaveSkill/DeleteSkill** - 技能管理(加载、沉淀/迭代、删除全局技能) 6. **MemoryAppend/Replace** - 对话记忆管理
7. **GroupManageAgent** - 群管理功能(如禁言 7. **LoadSkill/SaveSkill/DeleteSkill** - 技能管理(加载、沉淀/迭代、删除全局技能
8. **SendSingleMessage/CompositeMessage** - 发送消息 8. **GroupManageAgent** - 群管理功能(如禁言)
9. **SendVoiceMessage** - 发送语音消息 9. **SendSingleMessage/CompositeMessage** - 发送消息
10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合) 10. **SendVoiceMessage** - 发送语音消息
11. **WeatherService** - 天气查询 11. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索插件内置 SQLite 聊天历史 12. **WeatherService** - 天气查询
13. **QueryUserProfile** - 按当前发送者、QQ 号、群名片、昵称或好友备注读取长期画像,并针对该联系人按需读取公开资料卡 13. **SearchChatHistory** - 按关键词、发送者、时间范围搜索插件内置 SQLite 聊天历史
14. **QueryUserProfile** - 按当前发送者、QQ 号、群名片、昵称或好友备注读取长期画像,并针对该联系人按需读取公开资料卡
### GitHub 查询工具
在机器人运行环境安装 [GitHub CLI](https://cli.github.com/),并在 `githubToken` 中配置 fine-grained PAT。
建议只授予所需仓库的 `Metadata: Read`、`Contents: Read`、`Issues: Read` 和
`Pull requests: Read` 权限。插件通过子进程环境变量 `GH_TOKEN` 传递 Token,不需要执行 `gh auth login`
也不会把 Token 放进命令参数或日志。
模型通过统一的 `github` 工具多轮调用 `gh`,可使用 `search repos/code/issues/prs/commits`、
`repo view`、`issue list/view`、`pr list/view/diff/checks`、`release list/view` 和只读 REST `api`。
插件不提供 Shell,并拒绝写操作、登录、扩展、本地仓库操作、非 GitHub endpoint、超大查询和长时间运行。
认证请求仍受 GitHub API、Search API 和 secondary rate limit 限制。
## 用户画像系统 ## 用户画像系统
+6
View File
@@ -192,6 +192,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回") @ValueDescription("SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回")
val searXngUrl: String by value("") val searXngUrl: String by value("")
@ValueDescription("GitHub CLI 可执行文件路径;默认从系统 PATH 查找 gh")
val githubCliPath: String by value("gh")
@ValueDescription("GitHub fine-grained 只读 Token;留空时禁用 GitHub 工具,不会写入 GitHub 数据")
val githubToken: String by value("")
@ValueDescription("在线运行代码 glot.io 的 api token,在官网注册账号即可获取。") @ValueDescription("在线运行代码 glot.io 的 api token,在官网注册账号即可获取。")
val glotToken: String by value("") val glotToken: String by value("")
@@ -29,6 +29,7 @@ import top.jie65535.mirai.tools.AdjustUserFavorabilityAgent
import top.jie65535.mirai.tools.BaseAgent import top.jie65535.mirai.tools.BaseAgent
import top.jie65535.mirai.tools.DeleteSkill import top.jie65535.mirai.tools.DeleteSkill
import top.jie65535.mirai.tools.GroupManageAgent import top.jie65535.mirai.tools.GroupManageAgent
import top.jie65535.mirai.tools.GithubAgent
import top.jie65535.mirai.tools.ImageAgent import top.jie65535.mirai.tools.ImageAgent
import top.jie65535.mirai.tools.LoadSkill import top.jie65535.mirai.tools.LoadSkill
import top.jie65535.mirai.tools.MemoryAppend import top.jie65535.mirai.tools.MemoryAppend
@@ -71,6 +72,7 @@ internal object ConversationEngine {
SearchChatHistory(), SearchChatHistory(),
QueryUserProfileAgent(), QueryUserProfileAgent(),
WebSearch(), WebSearch(),
GithubAgent(),
VisitWeb(), VisitWeb(),
RunCode(), RunCode(),
ReasoningAgent(), ReasoningAgent(),
+262
View File
@@ -0,0 +1,262 @@
package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.*
import top.jie65535.mirai.config.PluginConfig
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
/**
* Provides a single, authenticated, read-only entry point to the GitHub CLI.
*
* The model can use the breadth of gh's query commands without receiving a
* general shell. Only read-oriented command families are accepted below.
*/
class GithubAgent : BaseAgent(
tool = Tool.function(
name = "github",
description = "使用已认证的 GitHub CLI 只读查询 GitHub。参数 args 是 gh 命令参数数组,不要包含 gh 本身。" +
"支持 search repos/code/issues/prs/commits、repo view、issue list/view、" +
"pr list/view/diff/checks、release list/view,以及只读 gh api。" +
"可通过多轮调用先搜索,再查看仓库、文件、Issue 或 PR;优先使用 --json。" +
"禁止写入、登录、安装扩展、执行本地代码或访问非 GitHub API。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("args") {
put("type", "array")
put("minItems", 1)
put("maxItems", MAX_ARGUMENTS)
putJsonObject("items") {
put("type", "string")
}
put(
"description",
"gh 命令参数数组,不含 gh。例如 [\"search\", \"code\", \"ChatCompletion language:kotlin\", \"--limit\", \"20\"]"
)
}
}
putJsonArray("required") {
add("args")
}
}
)
) {
override val isEnabled: Boolean
get() = PluginConfig.githubToken.isNotBlank() && PluginConfig.githubCliPath.isNotBlank()
override val loadingMessage: String
get() = "查询 GitHub 中..."
override suspend fun execute(args: JsonObject?): String {
requireNotNull(args)
val argsJson = args["args"] as? JsonArray
?: return "GitHub 工具参数错误:args 必须是字符串数组"
if (argsJson.isEmpty()) return "GitHub 工具参数错误:args 不能为空"
val cliArgs = buildList {
argsJson.forEachIndexed { index, value ->
val primitive = value as? JsonPrimitive
?: return "GitHub 工具参数错误:args[$index] 必须是字符串"
if (!primitive.isString) {
return "GitHub 工具参数错误:args[$index] 必须是字符串"
}
val content = primitive.contentOrNull
?: return "GitHub 工具参数错误:args[$index] 必须是字符串"
add(content)
}
}
val validationError = validateReadOnlyArgs(cliArgs)
if (validationError != null) return "GitHub 工具拒绝执行:$validationError"
return runCli(cliArgs)
}
private suspend fun runCli(args: List<String>): String = withContext(Dispatchers.IO) {
val executable = PluginConfig.githubCliPath.trim()
val process = try {
ProcessBuilder(listOf(executable) + args)
.redirectErrorStream(true)
.apply {
environment()["GH_TOKEN"] = PluginConfig.githubToken
environment()["GH_PROMPT_DISABLED"] = "1"
environment()["GH_PAGER"] = ""
environment()["NO_COLOR"] = "1"
}
.start()
} catch (e: IOException) {
return@withContext "无法启动 GitHub CLI,请检查 githubCliPath 和 gh 安装:${e.message}"
}
process.outputStream.close()
val output = ByteArrayOutputStream()
val truncated = AtomicBoolean(false)
val reader = thread(start = true, isDaemon = true, name = "jchatgpt-github-output") {
val buffer = ByteArray(8192)
var totalBytes = 0
try {
while (true) {
val count = process.inputStream.read(buffer)
if (count < 0) break
val remaining = MAX_OUTPUT_BYTES - totalBytes
if (remaining > 0) {
val accepted = minOf(count, remaining)
output.write(buffer, 0, accepted)
totalBytes += accepted
}
if (totalBytes >= MAX_OUTPUT_BYTES) {
truncated.set(true)
process.destroy()
break
}
}
} catch (_: IOException) {
// The process may close its stream while being terminated for a timeout or output cap.
}
}
val finished = process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (!finished) {
process.destroyForcibly()
reader.join(READER_JOIN_MILLIS)
return@withContext "GitHub CLI 执行超时(超过 ${PROCESS_TIMEOUT_SECONDS} 秒)"
}
reader.join(READER_JOIN_MILLIS)
val result = output.toString(StandardCharsets.UTF_8.name()).trim()
val suffix = if (truncated.get()) {
"\n\n[GitHub CLI 输出已截断,原始结果超过 ${MAX_OUTPUT_BYTES} 字节]"
} else {
""
}
if (process.exitValue() == 0 || truncated.get()) {
(result.ifEmpty { "GitHub CLI 未返回内容" }) + suffix
} else {
"GitHub CLI 执行失败(退出码 ${process.exitValue()}):\n" +
(result.ifEmpty { "未返回错误信息" }) + suffix
}
}
companion object {
internal const val MAX_ARGUMENTS = 48
internal const val MAX_ARGUMENT_LENGTH = 1000
internal const val MAX_TOTAL_ARGUMENT_LENGTH = 8000
internal const val MAX_OUTPUT_BYTES = 512 * 1024
private const val PROCESS_TIMEOUT_SECONDS = 30L
private const val READER_JOIN_MILLIS = 2000L
private val searchCommands = setOf("repos", "code", "issues", "prs", "commits")
private val readSubcommands = mapOf(
"repo" to setOf("view"),
"issue" to setOf("list", "view"),
"pr" to setOf("list", "view", "diff", "checks"),
"release" to setOf("list", "view"),
)
private val blockedArguments = setOf(
"--web",
"--hostname",
"--paginate",
"--slurp",
"--method",
"-X",
"--input",
"--raw-field",
"--field",
"-f",
"-F",
"--header",
"-H",
"--cache",
)
/** Returns null for an accepted read-only command, otherwise a user-readable rejection reason. */
internal fun validateReadOnlyArgs(args: List<String>): String? {
if (args.isEmpty()) return "args 不能为空"
if (args.size > MAX_ARGUMENTS) return "参数数量不能超过 $MAX_ARGUMENTS"
if (args.any { it.isEmpty() }) return "参数不能包含空字符串"
if (args.any { it.length > MAX_ARGUMENT_LENGTH }) {
return "单个参数长度不能超过 $MAX_ARGUMENT_LENGTH 个字符"
}
if (args.sumOf { it.length } > MAX_TOTAL_ARGUMENT_LENGTH) {
return "参数总长度不能超过 $MAX_TOTAL_ARGUMENT_LENGTH 个字符"
}
if (args.any { it.any(Char::isISOControl) }) return "参数不能包含控制字符"
val blocked = args.firstOrNull { argument ->
val option = argument.substringBefore('=')
option in blockedArguments
}
if (blocked != null) return "不允许使用参数 $blocked"
val accepted = when (args.first()) {
"help" -> args.size <= 2
"search" -> args.getOrNull(1) in searchCommands
else -> readSubcommands[args.first()]?.contains(args.getOrNull(1)) == true
}
if (!accepted && args.first() != "api") {
return "只允许 GitHub 搜索和读取类 gh 子命令"
}
validateLimit(args)?.let { return it }
if (args.first() == "api") return validateApiArgs(args)
return null
}
private fun validateLimit(args: List<String>): String? {
args.forEachIndexed { index, argument ->
if (argument == "--limit") {
val value = args.getOrNull(index + 1)
?: return "--limit 缺少数值"
val limit = value.toIntOrNull()
?: return "--limit 必须是整数"
if (limit !in 1..MAX_RESULTS) return "--limit 必须在 1 到 $MAX_RESULTS 之间"
} else if (argument.startsWith("--limit=")) {
val limit = argument.substringAfter('=').toIntOrNull()
?: return "--limit 必须是整数"
if (limit !in 1..MAX_RESULTS) return "--limit 必须在 1 到 $MAX_RESULTS 之间"
}
}
return null
}
private fun validateApiArgs(args: List<String>): String? {
val endpoint = args.getOrNull(1)
?: return "gh api 需要提供 endpoint"
if (endpoint.startsWith("-") || endpoint.contains("://") || endpoint == "graphql") {
return "gh api 仅允许访问 GitHub REST endpoint"
}
if (!endpoint.startsWith("repos/") &&
!endpoint.startsWith("search/") &&
endpoint != "user" &&
endpoint != "rate_limit"
) {
return "gh api 仅允许 repos、search、user 和 rate_limit endpoint"
}
var index = 2
while (index < args.size) {
when (args[index]) {
"--jq", "--template" -> {
if (args.getOrNull(index + 1).isNullOrBlank()) {
return "${args[index]} 缺少表达式"
}
index += 2
}
else -> return "gh api 只允许 endpoint、--jq 和 --template"
}
}
return null
}
private const val MAX_RESULTS = 50
}
}
+70
View File
@@ -0,0 +1,70 @@
package top.jie65535.mirai.tools
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class GithubAgentTest {
@Test
fun `common search and read commands are accepted`() {
assertNull(
GithubAgent.validateReadOnlyArgs(
listOf("search", "code", "ChatCompletion language:kotlin", "--limit", "20", "--json", "repository,path,url")
)
)
assertNull(
GithubAgent.validateReadOnlyArgs(
listOf("pr", "view", "123", "--repo", "owner/repo", "--json", "title,body,comments")
)
)
assertNull(
GithubAgent.validateReadOnlyArgs(
listOf("api", "repos/owner/repo/pulls/123/files", "--jq", ".[].[filename,status]")
)
)
}
@Test
fun `write and local state commands are rejected`() {
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("issue", "create", "--title", "test")))
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("pr", "merge", "123")))
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("repo", "clone", "owner/repo")))
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("auth", "token")))
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("extension", "install", "owner/extension")))
}
@Test
fun `api remains read only and limited to github rest endpoints`() {
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("api", "graphql")))
assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("api", "https://example.com/private")))
assertNotNull(
GithubAgent.validateReadOnlyArgs(
listOf("api", "repos/owner/repo/issues", "--method", "POST")
)
)
assertNotNull(
GithubAgent.validateReadOnlyArgs(
listOf("api", "orgs/owner/repos")
)
)
}
@Test
fun `result limits and unsafe global flags are rejected`() {
assertNotNull(
GithubAgent.validateReadOnlyArgs(
listOf("search", "issues", "bug", "--limit", "51")
)
)
assertNotNull(
GithubAgent.validateReadOnlyArgs(
listOf("repo", "view", "owner/repo", "--web")
)
)
assertNotNull(
GithubAgent.validateReadOnlyArgs(
listOf("search", "repos", "kotlin", "--hostname=example.com")
)
)
}
}