github: broaden read-only CLI access

This commit is contained in:
2026-08-07 11:06:13 +08:00
parent 19475b3ee8
commit 7e15ce5981
3 changed files with 119 additions and 91 deletions
+3 -3
View File
@@ -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 限制。
## 用户画像系统
+96 -82
View File
@@ -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 和 Actionsargs 是不包含 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>): 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>): 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,
)
}
}
+20 -6
View File
@@ -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