Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions docs/optimization-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@

| # | 问题 | 影响 | 优先级 | 备注 |
|---|--------------------------------------------------------------------|------|--------|------|
| E-01 | 权限逻辑反转:当前白名单外的命令被 blocked,应改为「白名单内免审批,白名单外弹窗确认后加入白名单,无命令被直接 block」 | 与用户预期严重不符 | P0 | 实际:blocked without prompting;预期:白名单外仍可执行但需每次确认 |


### 安全

Expand All @@ -83,8 +83,9 @@

| # | 问题 | 解决方式 | 解决时间 |
|---|------|---------|---------|
| ~~X-01~~ | SSE 错误处理不完整,400 错误无响应体 | PR #1 fix/sse-error-handling | 2024 |
| ~~X-02~~ | 流式输出无法中断 | PR #3 cancel stream + ESC | 2024 |
| ~~U-07~~ | 命令执行卡片展示顺序错误:执行结果在下、AI 响应在上,用户无法第一时间看到回复 | 引入 bridgeNotifiedStart 延迟 notifyStart,工具启用时气泡在 ExecutionCard 之后创建 | 2025-04-15 |
| ~~U-08~~ | 命令执行完成后卡片不自动折叠,占据大量空间;缺少展开/折叠按钮 | ExecutionCard 完成后默认折叠,提供点击展开 | 2025-04-15 |
| ~~A-05~~ | `CommandExecutionService` 硬编码 `sh -c`,仅支持 Unix;路径校验和默认白名单也是 Unix-only | 引入 `ShellPlatform` sealed class(Unix/Windows),封装 ProcessBuilder、路径校验、工具定义、白名单;ChatService 和 Settings 均委托给 `ShellPlatform.current()` | 2026-04-15 |
| ~~X-01~~ | SSE 错误处理不完整,400 错误无响应体 | PR #1 | 2026-04 |
| ~~X-02~~ | 流式输出无法中断 | PR #3 | 2026-04 |
| ~~U-07~~ | 命令执行卡片展示顺序错误:执行结果在下、AI 响应在上,用户无法第一时间看到回复 | PR #4 | 2026-04-15 |
| ~~U-08~~ | 命令执行完成后卡片不自动折叠,占据大量空间;缺少展开/折叠按钮 | PR #4 | 2026-04-15 |
| ~~A-05~~ | `CommandExecutionService` 硬编码 `sh -c`,仅支持 Unix;路径校验和默认白名单也是 Unix-only | PR #7 | 2026-04-15 |
| ~~E-01~~ | 权限逻辑反转:白名单外命令被 blocked,应改为白名单内免审批、白名单外弹窗确认 | PR #8 | 2026-04-15 |
29 changes: 20 additions & 9 deletions src/main/kotlin/com/github/codeplangui/BridgeHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@ private data class BridgePayload(
val text: String = "",
val includeContext: Boolean = true,
val requestId: String = "",
val decision: String = ""
val decision: String = "",
val addToWhitelist: Boolean = false
)

internal interface BridgeCommands {
fun sendMessage(text: String, includeContext: Boolean)
fun newChat()
fun openSettings()
fun onFrontendReady()
fun approvalResponse(requestId: String, decision: String)
fun approvalResponse(requestId: String, decision: String, addToWhitelist: Boolean)
fun debugLog(text: String)
fun cancelStream()
}
Expand All @@ -37,14 +38,15 @@ internal fun dispatchBridgeRequest(
includeContext: Boolean,
requestId: String = "",
decision: String = "",
addToWhitelist: Boolean = false,
commands: BridgeCommands
) {
when (type) {
"sendMessage" -> commands.sendMessage(text, includeContext)
"newChat" -> commands.newChat()
"openSettings" -> commands.openSettings()
"frontendReady" -> commands.onFrontendReady()
"approvalResponse" -> commands.approvalResponse(requestId, decision)
"approvalResponse" -> commands.approvalResponse(requestId, decision, addToWhitelist)
"debugLog" -> commands.debugLog(text)
"cancelStream" -> commands.cancelStream()
}
Expand All @@ -68,7 +70,7 @@ internal fun handleBridgePayload(
}

return try {
dispatchBridgeRequest(req.type, req.text, req.includeContext, req.requestId, req.decision, commands)
dispatchBridgeRequest(req.type, req.text, req.includeContext, req.requestId, req.decision, req.addToWhitelist, commands)
BridgePayloadHandlingResult.Success
} catch (e: Exception) {
BridgePayloadHandlingResult.CommandError(
Expand Down Expand Up @@ -115,9 +117,9 @@ class BridgeHandler(
chatService.onFrontendReady()
}

override fun approvalResponse(requestId: String, decision: String) {
logger.info("[CodePlanGUI Bridge] frontend->ide approvalResponse requestId=$requestId decision=$decision")
chatService.onApprovalResponse(requestId, decision)
override fun approvalResponse(requestId: String, decision: String, addToWhitelist: Boolean) {
logger.info("[CodePlanGUI Bridge] frontend->ide approvalResponse requestId=$requestId decision=$decision addToWhitelist=$addToWhitelist")
chatService.onApprovalResponse(requestId, decision, addToWhitelist)
}

override fun debugLog(text: String) {
Expand Down Expand Up @@ -164,8 +166,8 @@ class BridgeHandler(
frontendReady: function() {
${sendQuery.inject("""JSON.stringify({type:'frontendReady',text:''})""")}
},
approvalResponse: function(requestId, decision) {
${sendQuery.inject("""JSON.stringify({type:'approvalResponse',text:'',requestId:requestId,decision:decision})""")}
approvalResponse: function(requestId, decision, addToWhitelist) {
${sendQuery.inject("""JSON.stringify({type:'approvalResponse',text:'',requestId:requestId,decision:decision,addToWhitelist:!!addToWhitelist})""")}
},
debugLog: function(text) {
${sendQuery.inject("""JSON.stringify({type:'debugLog',text:text})""")}
Expand All @@ -178,6 +180,7 @@ class BridgeHandler(
onContextFile: function(fileName) {},
onTheme: function(theme) {},
onApprovalRequest: function(requestId, command, description) {},
onExecutionCard: function(requestId, command, description) {},
onLog: function(msgId, logLine, type) {},
onExecutionStatus: function(requestId, status, result) {},
onRestoreMessages: function(messages) {}
Expand Down Expand Up @@ -215,6 +218,14 @@ class BridgeHandler(
"${json.encodeToString(type)})"
)

fun notifyExecutionCard(requestId: String, command: String, description: String) =
pushJS(
"window.__bridge.onExecutionCard(" +
"${json.encodeToString(requestId)}," +
"${json.encodeToString(command)}," +
"${json.encodeToString(description)})"
)

fun notifyApprovalRequest(requestId: String, command: String, description: String) =
pushJS(
"window.__bridge.onApprovalRequest(" +
Expand Down
101 changes: 61 additions & 40 deletions src/main/kotlin/com/github/codeplangui/ChatService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class ChatService(private val project: Project) : Disposable {

// Approval gate: suspended coroutines wait on these futures
private val pendingApprovals = ConcurrentHashMap<String, CompletableFuture<Boolean>>()
private val pendingApprovalCommands = ConcurrentHashMap<String, String>()

// Tracks which msgIds have had notifyStart sent to the frontend
// When tools are enabled, notifyStart is deferred until the final response round
Expand Down Expand Up @@ -187,6 +188,7 @@ class ChatService(private val project: Project) : Disposable {
session = ChatSession()
pendingApprovals.values.forEach { it.complete(false) }
pendingApprovals.clear()
pendingApprovalCommands.clear()
sessionStore.clearSession()
contextFileCallback?.invoke("")
publishStatus()
Expand Down Expand Up @@ -214,11 +216,22 @@ class ChatService(private val project: Project) : Disposable {
)
}

fun onApprovalResponse(requestId: String, decision: String) {
fun onApprovalResponse(requestId: String, decision: String, addToWhitelist: Boolean = false) {
logger.info(
"[CodePlanGUI Approval] received frontend decision " +
"requestId=$requestId decision=$decision hasPending=${pendingApprovals.containsKey(requestId)}"
"requestId=$requestId decision=$decision addToWhitelist=$addToWhitelist hasPending=${pendingApprovals.containsKey(requestId)}"
)
if (addToWhitelist && decision == "allow") {
val command = pendingApprovalCommands[requestId]
if (command != null) {
val baseCommand = CommandExecutionService.extractBaseCommand(command)
val whitelist = PluginSettings.getInstance().getState().commandWhitelist
if (baseCommand !in whitelist) {
whitelist.add(baseCommand)
logger.info("[CodePlanGUI Approval] added '$baseCommand' to whitelist (from command: ${command.summarizeForLog()})")
}
}
}
pendingApprovals[requestId]?.complete(decision == "allow")
}

Expand Down Expand Up @@ -505,20 +518,7 @@ $selection
"description=${toolCall.description.summarizeForLog()}"
)

bridgeHandler?.notifyApprovalRequest(requestId, toolCall.command, toolCall.description)

if (!CommandExecutionService.isWhitelisted(toolCall.command, state.commandWhitelist)) {
logger.info(
"[CodePlanGUI Approval] blocked by whitelist " +
"requestId=$requestId index=${toolCall.index} command=${toolCall.command.summarizeForLog()}"
)
val result = ExecutionResult.Blocked(
toolCall.command,
"'${CommandExecutionService.extractBaseCommand(toolCall.command)}' is not in the allowed command list"
)
bridgeHandler?.notifyExecutionStatus(requestId, "blocked", result.toToolResultContent())
return CompletedToolCall(toolCall, result)
}
bridgeHandler?.notifyExecutionCard(requestId, toolCall.command, toolCall.description)

val basePath = project.basePath ?: ""
if (CommandExecutionService.hasPathsOutsideWorkspace(toolCall.command, basePath)) {
Expand All @@ -532,34 +532,54 @@ $selection
return CompletedToolCall(toolCall, result)
}

bridgeHandler?.notifyLog(requestId, "Security check passed", "info")

val future = CompletableFuture<Boolean>()
pendingApprovals[requestId] = future
bridgeHandler?.notifyExecutionStatus(requestId, "waiting", "{}")
bridgeHandler?.notifyLog(requestId, "Waiting for approval...", "info")
logger.info("[CodePlanGUI Approval] waiting for user decision requestId=$requestId index=${toolCall.index}")
logger.info(
"[CodePlanGUI Approval] whitelist check " +
"requestId=$requestId baseCommand=${CommandExecutionService.extractBaseCommand(toolCall.command)} " +
"whitelist=${state.commandWhitelist}"
)

val approved = try {
withContext(Dispatchers.IO) { future.get(60, TimeUnit.SECONDS) }
} catch (e: Exception) {
val whitelisted = CommandExecutionService.isWhitelisted(toolCall.command, state.commandWhitelist)
if (!whitelisted) {
logger.info(
"[CodePlanGUI Approval] decision wait failed " +
"requestId=$requestId index=${toolCall.index} error=${e.javaClass.simpleName}:${e.message ?: ""}"
"[CodePlanGUI Approval] command not in whitelist, requesting approval " +
"requestId=$requestId index=${toolCall.index} command=${toolCall.command.summarizeForLog()}"
)
bridgeHandler?.notifyApprovalRequest(requestId, toolCall.command, toolCall.description)
bridgeHandler?.notifyExecutionStatus(requestId, "waiting", "{}")
bridgeHandler?.notifyLog(requestId, "Waiting for approval...", "info")

val future = CompletableFuture<Boolean>()
pendingApprovals[requestId] = future
pendingApprovalCommands[requestId] = toolCall.command

val approved = try {
withContext(Dispatchers.IO) { future.get(60, TimeUnit.SECONDS) }
} catch (e: Exception) {
logger.info(
"[CodePlanGUI Approval] decision wait failed " +
"requestId=$requestId index=${toolCall.index} error=${e.javaClass.simpleName}:${e.message ?: ""}"
)
false
} finally {
pendingApprovals.remove(requestId)
pendingApprovalCommands.remove(requestId)
}
logger.info(
"[CodePlanGUI Approval] resolved user decision " +
"requestId=$requestId index=${toolCall.index} approved=$approved"
)
false
} finally {
pendingApprovals.remove(requestId)
}
logger.info(
"[CodePlanGUI Approval] resolved user decision " +
"requestId=$requestId index=${toolCall.index} approved=$approved"
)

if (!approved) {
val result = ExecutionResult.Denied(toolCall.command, "User rejected the command")
bridgeHandler?.notifyExecutionStatus(requestId, "denied", result.toToolResultContent())
return CompletedToolCall(toolCall, result)
if (!approved) {
val result = ExecutionResult.Denied(toolCall.command, "User rejected the command")
bridgeHandler?.notifyExecutionStatus(requestId, "denied", result.toToolResultContent())
return CompletedToolCall(toolCall, result)
}
} else {
bridgeHandler?.notifyLog(requestId, "Command whitelisted, auto-approved", "info")
logger.info(
"[CodePlanGUI Approval] command is whitelisted, auto-approving " +
"requestId=$requestId index=${toolCall.index} command=${toolCall.command.summarizeForLog()}"
)
}

bridgeHandler?.notifyExecutionStatus(requestId, "running", "{}")
Expand Down Expand Up @@ -618,6 +638,7 @@ $selection
activeStream?.cancel()
pendingApprovals.values.forEach { it.complete(false) }
pendingApprovals.clear()
pendingApprovalCommands.clear()
bridgeNotifiedStart.clear()
scope.cancel()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,25 @@ class CommandExecutionService(private val project: Project) {
process.destroyForcibly()
ExecutionResult.TimedOut(
command = command,
stdout = truncateOutput(stdout, 4000),
stdout = truncateOutput(stdout, 20000),
timeoutSeconds = timeoutSeconds
)
} else {
val truncated = stdout.length > 4000 || stderr.length > 2000
val truncated = stdout.length > 20000 || stderr.length > 10000
if (process.exitValue() == 0) {
ExecutionResult.Success(
command = command,
stdout = truncateOutput(stdout, 4000),
stderr = truncateOutput(stderr, 2000),
stdout = truncateOutput(stdout, 20000),
stderr = truncateOutput(stderr, 10000),
exitCode = 0,
durationMs = durationMs,
truncated = truncated
)
} else {
ExecutionResult.Failed(
command = command,
stdout = truncateOutput(stdout, 4000),
stderr = truncateOutput(stderr, 2000),
stdout = truncateOutput(stdout, 20000),
stderr = truncateOutput(stderr, 10000),
exitCode = process.exitValue(),
durationMs = durationMs,
truncated = truncated
Expand Down Expand Up @@ -101,25 +101,25 @@ class CommandExecutionService(private val project: Project) {
process.destroyForcibly()
ExecutionResult.TimedOut(
command = command,
stdout = truncateOutput(stdout, 4000),
stdout = truncateOutput(stdout, 20000),
timeoutSeconds = timeoutSeconds
)
} else {
val truncated = stdout.length > 4000 || stderr.length > 2000
val truncated = stdout.length > 20000 || stderr.length > 10000
if (process.exitValue() == 0) {
ExecutionResult.Success(
command = command,
stdout = truncateOutput(stdout, 4000),
stderr = truncateOutput(stderr, 2000),
stdout = truncateOutput(stdout, 20000),
stderr = truncateOutput(stderr, 10000),
exitCode = 0,
durationMs = durationMs,
truncated = truncated
)
} else {
ExecutionResult.Failed(
command = command,
stdout = truncateOutput(stdout, 4000),
stderr = truncateOutput(stderr, 2000),
stdout = truncateOutput(stdout, 20000),
stderr = truncateOutput(stderr, 10000),
exitCode = process.exitValue(),
durationMs = durationMs,
truncated = truncated
Expand Down
Loading
Loading