From 7e15ce598114e79ebbf24f8fc18d9e45233be892 Mon Sep 17 00:00:00 2001 From: jie65535 Date: Fri, 7 Aug 2026 11:06:13 +0800 Subject: [PATCH] github: broaden read-only CLI access --- README.md | 6 +- src/main/kotlin/tools/GithubAgent.kt | 178 ++++++++++++----------- src/test/kotlin/tools/GithubAgentTest.kt | 26 +++- 3 files changed, 119 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 94b9dba..fbf6134 100644 --- a/README.md +++ b/README.md @@ -517,9 +517,9 @@ fallbackCooldownMinutes: 5 `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` 工具多轮调用 `gh`,可搜索并读取用户、仓库、代码、Issue、PR、Release 和 +GitHub Actions,也可对相对 GitHub REST endpoint 执行只读 `api` 请求。插件不提供 Shell,并拒绝写操作、 +登录、扩展、本地仓库操作、切换 GitHub 主机、超大查询和长时间运行。 认证请求仍受 GitHub API、Search API 和 secondary rate limit 限制。 ## 用户画像系统 diff --git a/src/main/kotlin/tools/GithubAgent.kt b/src/main/kotlin/tools/GithubAgent.kt index 89cd55d..fb10d7c 100644 --- a/src/main/kotlin/tools/GithubAgent.kt +++ b/src/main/kotlin/tools/GithubAgent.kt @@ -3,6 +3,9 @@ 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.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import kotlinx.serialization.json.* import top.jie65535.mirai.config.PluginConfig @@ -10,8 +13,6 @@ 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. @@ -22,11 +23,9 @@ import kotlin.concurrent.thread 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。", + description = "使用已认证的 GitHub CLI 只读查询 GitHub。可读取用户、仓库、代码、Issue、" + + "Pull Request、Release 和 Actions;args 是不包含 gh 的参数数组,可多轮先搜索再查看详情," + + "优先使用 --json。禁止写入、登录、扩展、本地仓库操作和非 GitHub API 访问。", parameters = Parameters.buildJsonObject { put("type", "object") putJsonObject("properties") { @@ -39,7 +38,7 @@ class GithubAgent : BaseAgent( } put( "description", - "gh 命令参数数组,不含 gh。例如 [\"search\", \"code\", \"ChatCompletion language:kotlin\", \"--limit\", \"20\"]" + "gh 命令参数数组,不含 gh。例如 [\"repo\", \"view\", \"owner/repo\", \"--json\", \"name,description,url\"]" ) } } @@ -81,68 +80,74 @@ class GithubAgent : BaseAgent( } private suspend fun runCli(args: List): 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() + coroutineScope { + 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@coroutineScope "无法启动 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 outputReader = async(Dispatchers.IO) { captureOutput(process) } + val finished = process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + runCatching { process.inputStream.close() } + outputReader.cancelAndJoin() + return@coroutineScope "GitHub CLI 执行超时(超过 ${PROCESS_TIMEOUT_SECONDS} 秒)" + } + + val captured = outputReader.await() + val suffix = if (captured.truncated) { + "\n\n[GitHub CLI 输出已截断,原始结果超过 ${MAX_OUTPUT_BYTES} 字节]" + } else { + "" + } + if (process.exitValue() == 0 || captured.truncated) { + (captured.content.ifEmpty { "GitHub CLI 未返回内容" }) + suffix + } else { + "GitHub CLI 执行失败(退出码 ${process.exitValue()}):\n" + + (captured.content.ifEmpty { "未返回错误信息" }) + suffix } } + } - 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 + private fun captureOutput(process: Process): CapturedOutput { + val output = ByteArrayOutputStream() + val buffer = ByteArray(8192) + var totalBytes = 0 + var truncated = false + try { + while (true) { + val count = process.inputStream.read(buffer) + if (count < 0) break + val accepted = minOf(count, MAX_OUTPUT_BYTES - totalBytes) + if (accepted > 0) { + output.write(buffer, 0, accepted) + totalBytes += accepted + } + if (totalBytes >= MAX_OUTPUT_BYTES) { + truncated = true + process.destroy() + break + } + } + } catch (_: IOException) { + // The stream may close while the process is terminated for a timeout or output cap. } + return CapturedOutput( + content = output.toString(StandardCharsets.UTF_8.name()).trim(), + truncated = truncated, + ) } companion object { @@ -151,20 +156,20 @@ class GithubAgent : BaseAgent( 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"), + "repo" to setOf("list", "view"), + "issue" to setOf("list", "status", "view"), + "pr" to setOf("checks", "diff", "list", "status", "view"), "release" to setOf("list", "view"), + "run" to setOf("list", "view"), + "workflow" to setOf("list", "view"), + "gist" to setOf("list", "view"), + "org" to setOf("list"), ) private val blockedArguments = setOf( "--web", "--hostname", - "--paginate", - "--slurp", "--method", "-X", "--input", @@ -230,15 +235,8 @@ class GithubAgent : BaseAgent( private fun validateApiArgs(args: List): 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" + if (!isRelativeRestEndpoint(endpoint)) { + return "gh api 仅允许访问相对 GitHub REST endpoint" } var index = 2 @@ -251,12 +249,28 @@ class GithubAgent : BaseAgent( index += 2 } - else -> return "gh api 只允许 endpoint、--jq 和 --template" + "--paginate", "--slurp" -> index++ + + else -> return "gh api 只允许 endpoint、--jq、--template、--paginate 和 --slurp" } } return null } + private fun isRelativeRestEndpoint(endpoint: String): Boolean { + val path = endpoint.substringBefore('?') + if (path.isBlank() || path.startsWith("-") || path.startsWith('/') || path.startsWith('\\')) return false + if (path.contains("://") || path == "graphql" || path.startsWith("graphql/") || endpoint.contains('\\')) { + return false + } + return path.split('/').none { it.isBlank() || it == "." || it == ".." } + } + private const val MAX_RESULTS = 50 + + private data class CapturedOutput( + val content: String, + val truncated: Boolean, + ) } } diff --git a/src/test/kotlin/tools/GithubAgentTest.kt b/src/test/kotlin/tools/GithubAgentTest.kt index f007ab5..57adf39 100644 --- a/src/test/kotlin/tools/GithubAgentTest.kt +++ b/src/test/kotlin/tools/GithubAgentTest.kt @@ -22,6 +22,19 @@ class GithubAgentTest { listOf("api", "repos/owner/repo/pulls/123/files", "--jq", ".[].[filename,status]") ) ) + assertNull( + GithubAgent.validateReadOnlyArgs( + listOf("api", "users/ZhengHe000", "--jq", "{login,name,followers,public_repos}") + ) + ) + assertNull( + GithubAgent.validateReadOnlyArgs( + listOf("api", "orgs/github/repos?per_page=50", "--paginate", "--slurp") + ) + ) + assertNull(GithubAgent.validateReadOnlyArgs(listOf("repo", "list", "github", "--limit", "20"))) + assertNull(GithubAgent.validateReadOnlyArgs(listOf("run", "view", "123", "--repo", "owner/repo"))) + assertNull(GithubAgent.validateReadOnlyArgs(listOf("workflow", "list", "--repo", "owner/repo"))) } @Test @@ -34,19 +47,20 @@ class GithubAgentTest { } @Test - fun `api remains read only and limited to github rest endpoints`() { + fun `api remains read only and limited to relative github rest endpoints`() { assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("api", "graphql"))) + assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("api", "graphql?query=viewer"))) assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("api", "https://example.com/private"))) + assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("api", "//example.com/private"))) + assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("api", "/repos/owner/repo"))) + assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("api", "repos/../user"))) + assertNull(GithubAgent.validateReadOnlyArgs(listOf("api", "search/issues?q=url:https://example.com"))) assertNotNull( GithubAgent.validateReadOnlyArgs( listOf("api", "repos/owner/repo/issues", "--method", "POST") ) ) - assertNotNull( - GithubAgent.validateReadOnlyArgs( - listOf("api", "orgs/owner/repos") - ) - ) + assertNotNull(GithubAgent.validateReadOnlyArgs(listOf("api", "repos/owner/repo/issues", "-f", "state=open"))) } @Test