mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
github: broaden read-only CLI access
This commit is contained in:
@@ -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 限制。
|
||||
|
||||
## 用户画像系统
|
||||
|
||||
@@ -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>): 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user