mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-05-19 16:28:46 +00:00
feat: ignore and permissions
This commit is contained in:
parent
a7864d16b1
commit
3cd9f18d4a
16 changed files with 529 additions and 92 deletions
|
|
@ -37,7 +37,6 @@ import ee.carlrobert.codegpt.agent.credits.extractCreditsSnapshot
|
|||
import ee.carlrobert.codegpt.agent.tools.*
|
||||
import ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey
|
||||
import ee.carlrobert.codegpt.credentials.CredentialsStore.getCredential
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import ee.carlrobert.codegpt.settings.hooks.HookManager
|
||||
import ee.carlrobert.codegpt.settings.service.FeatureType
|
||||
import ee.carlrobert.codegpt.settings.service.ModelSelectionService
|
||||
|
|
@ -589,10 +588,9 @@ object AgentFactory {
|
|||
if (SubagentTool.BASH in selected) {
|
||||
tool(
|
||||
BashTool(
|
||||
project.basePath ?: "",
|
||||
project,
|
||||
confirmationHandler = bashConfirmationHandler,
|
||||
sessionId = sessionId,
|
||||
settingsService = project.service<ProxyAISettingsService>(),
|
||||
hookManager = hookManager
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import ee.carlrobert.codegpt.agent.strategy.HistoryCompressionConfig
|
|||
import ee.carlrobert.codegpt.agent.strategy.SingleRunStrategyProvider
|
||||
import ee.carlrobert.codegpt.agent.strategy.buildHistoryTooBigPredicate
|
||||
import ee.carlrobert.codegpt.agent.tools.*
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
|
||||
import ee.carlrobert.codegpt.settings.hooks.HookEventType
|
||||
import ee.carlrobert.codegpt.settings.hooks.HookManager
|
||||
|
|
@ -343,7 +342,7 @@ object ProxyAIAgent {
|
|||
)
|
||||
tool(
|
||||
BashTool(
|
||||
workingDirectory = workingDirectory,
|
||||
project = project,
|
||||
confirmationHandler = { args ->
|
||||
try {
|
||||
val approved = events.approveToolCall(
|
||||
|
|
@ -362,7 +361,6 @@ object ProxyAIAgent {
|
|||
}
|
||||
},
|
||||
sessionId = sessionId,
|
||||
settingsService = project.service<ProxyAISettingsService>(),
|
||||
hookManager = hookManager
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.intellij.openapi.application.runReadAction
|
|||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.application.runWriteAction
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import com.intellij.openapi.project.Project
|
||||
|
|
@ -37,7 +38,6 @@ class RollbackService(private val project: Project) {
|
|||
|
||||
private val activeRuns = ConcurrentHashMap<String, RunTracker>()
|
||||
private val snapshots = ConcurrentHashMap<String, SnapshotState>()
|
||||
private val settingsService = project.getService(ProxyAISettingsService::class.java)
|
||||
|
||||
@Volatile
|
||||
private var isApplyingRollback = false
|
||||
|
|
@ -199,7 +199,7 @@ class RollbackService(private val project: Project) {
|
|||
if (file != null) {
|
||||
if (file.isDirectory || !file.isValid) return false
|
||||
if (FileTypeManager.getInstance().isFileIgnored(file)) return false
|
||||
if (settingsService.isPathIgnored(file.path)) return false
|
||||
if (project.service<ProxyAISettingsService>().isPathIgnored(file.path)) return false
|
||||
return file.length <= MAX_TRACKABLE_BYTES
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +207,7 @@ class RollbackService(private val project: Project) {
|
|||
.getOrNull()
|
||||
?: path.substringAfterLast('/')
|
||||
if (FileTypeManager.getInstance().isFileIgnored(fileName)) return false
|
||||
if (settingsService.isPathIgnored(path)) return false
|
||||
if (project.service<ProxyAISettingsService>().isPathIgnored(path)) return false
|
||||
|
||||
val ioFile = File(path)
|
||||
if (!ioFile.exists()) return false
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ abstract class BaseTool<Args : Any, Result : Any>(
|
|||
resultSerializer: kotlinx.serialization.KSerializer<Result>,
|
||||
name: String,
|
||||
description: String,
|
||||
private val workingDirectory: String,
|
||||
protected val workingDirectory: String,
|
||||
private val hookManager: HookManager,
|
||||
private val sessionId: String? = null,
|
||||
private val argsClass: KClass<Args>,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import com.intellij.openapi.application.ApplicationManager
|
|||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.application.runWriteAction
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFileManager
|
||||
import ee.carlrobert.codegpt.agent.AgentToolOutputNotifier
|
||||
import ee.carlrobert.codegpt.agent.ToolRunContext
|
||||
|
|
@ -30,13 +32,12 @@ fun interface BashCommandConfirmationHandler {
|
|||
}
|
||||
|
||||
class BashTool(
|
||||
private val workingDirectory: String,
|
||||
private val project: Project,
|
||||
private val confirmationHandler: BashCommandConfirmationHandler,
|
||||
private val sessionId: String = "global",
|
||||
private val settingsService: ProxyAISettingsService? = null,
|
||||
private val hookManager: HookManager
|
||||
) : BaseTool<BashTool.Args, BashTool.Result>(
|
||||
workingDirectory = workingDirectory,
|
||||
workingDirectory = project.basePath ?: "",
|
||||
argsSerializer = Args.serializer(),
|
||||
resultSerializer = Result.serializer(),
|
||||
name = "Bash",
|
||||
|
|
@ -46,7 +47,7 @@ class BashTool(
|
|||
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||||
|
||||
<env>
|
||||
Working directory: $$workingDirectory
|
||||
Working directory: $${project.basePath ?: ""}
|
||||
</env>
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
|
|
@ -215,27 +216,22 @@ class BashTool(
|
|||
)
|
||||
|
||||
private fun isWhiteListed(args: Args): Boolean {
|
||||
val permissions = settingsService?.getBashPermissions() ?: emptyList()
|
||||
return permissions.any { pattern ->
|
||||
when {
|
||||
pattern.startsWith("Bash(") && pattern.endsWith(":*)") -> {
|
||||
val commandPattern = pattern.removePrefix("Bash(").removeSuffix(":*)")
|
||||
args.command.startsWith(commandPattern)
|
||||
}
|
||||
|
||||
pattern.startsWith("Bash(") && pattern.endsWith(")") -> {
|
||||
val commandPattern = pattern.removePrefix("Bash(").removeSuffix(")")
|
||||
args.command == commandPattern
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
return project.service<ProxyAISettingsService>()
|
||||
.isToolInvocationWhitelisted(this, args.command)
|
||||
}
|
||||
|
||||
|
||||
override suspend fun doExecute(args: Args): Result {
|
||||
val toolId = ToolRunContext.getToolId(sessionId)
|
||||
if (project.service<ProxyAISettingsService>()
|
||||
.isToolInvocationDenied(this, args.command)
|
||||
) {
|
||||
return Result(
|
||||
args.command,
|
||||
null,
|
||||
"Access denied by permissions.deny for Bash",
|
||||
null
|
||||
)
|
||||
}
|
||||
if (shouldBlockByIgnore(args.command)) {
|
||||
return Result(
|
||||
args.command,
|
||||
|
|
@ -571,7 +567,6 @@ class BashTool(
|
|||
}
|
||||
|
||||
private fun shouldBlockByIgnore(command: String): Boolean {
|
||||
val svc = settingsService ?: return false
|
||||
val readers = setOf(
|
||||
"cat",
|
||||
"grep",
|
||||
|
|
@ -614,7 +609,10 @@ class BashTool(
|
|||
if (!tokenIsReader && looksLikePath(t)) paths.add(t)
|
||||
lastWasReader = tokenIsReader
|
||||
}
|
||||
return paths.any { candidate -> svc.isPathIgnored(toAbsolute(candidate)) }
|
||||
val settingsService = project.service<ProxyAISettingsService>()
|
||||
return paths.any { candidate ->
|
||||
settingsService.isPathIgnored(toAbsolute(candidate))
|
||||
}
|
||||
}
|
||||
|
||||
private fun tokenize(s: String): List<String> {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package ee.carlrobert.codegpt.agent.tools
|
|||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.project.Project
|
||||
|
|
@ -131,7 +132,7 @@ class EditTool(
|
|||
|
||||
override suspend fun doExecute(args: Args): Result {
|
||||
return try {
|
||||
val svc = project.getService(ProxyAISettingsService::class.java)
|
||||
val svc = project.service<ProxyAISettingsService>()
|
||||
if (svc.isPathIgnored(args.filePath)) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.intellij.ide.util.gotoByName.GotoClassModel2
|
|||
import com.intellij.ide.util.gotoByName.GotoFileModel
|
||||
import com.intellij.ide.util.gotoByName.GotoSymbolModel2
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.project.guessProjectDir
|
||||
|
|
@ -270,7 +271,7 @@ class IntelliJSearchTool(
|
|||
val context = if (psiFile != null) getContextText(psiFile, offset) else null
|
||||
|
||||
val displayFile = vf?.path ?: (psiFile?.name ?: psi.toString())
|
||||
val svc = project.getService(ProxyAISettingsService::class.java)
|
||||
val svc = project.service<ProxyAISettingsService>()
|
||||
if (vf?.path != null && svc.isPathIgnored(vf.path)) continue
|
||||
|
||||
results.add(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package ee.carlrobert.codegpt.agent.tools
|
|||
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
|
|
@ -9,6 +10,7 @@ import com.intellij.openapi.project.Project
|
|||
import com.intellij.openapi.vfs.VirtualFileManager
|
||||
import ee.carlrobert.codegpt.agent.ToolRunContext
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import ee.carlrobert.codegpt.settings.ToolPermissionPolicy
|
||||
import ee.carlrobert.codegpt.settings.hooks.HookEventType
|
||||
import ee.carlrobert.codegpt.settings.hooks.HookManager
|
||||
import ee.carlrobert.codegpt.tokens.truncateToolResult
|
||||
|
|
@ -94,11 +96,27 @@ class ReadTool(
|
|||
}
|
||||
|
||||
override suspend fun doExecute(args: Args): Result {
|
||||
val settingsService = project.service<ProxyAISettingsService>()
|
||||
val decision = settingsService.evaluateToolPermission(this, args.filePath)
|
||||
if (decision == ToolPermissionPolicy.Decision.DENY) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Access denied by permissions.deny for Read"
|
||||
)
|
||||
}
|
||||
if (settingsService.hasAllowRulesForTool("Read")
|
||||
&& decision != ToolPermissionPolicy.Decision.ALLOW
|
||||
) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Access denied by permissions.allow for Read"
|
||||
)
|
||||
}
|
||||
|
||||
return try {
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
runReadAction {
|
||||
val svc = project.getService(ProxyAISettingsService::class.java)
|
||||
if (svc.isPathIgnored(args.filePath)) {
|
||||
if (settingsService.isPathIgnored(args.filePath)) {
|
||||
return@runReadAction Result.Error(
|
||||
filePath = args.filePath,
|
||||
error = "Access to this path is blocked by .proxyai ignore rules"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import ai.koog.agents.core.tools.annotations.LLMDescription
|
|||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.application.runWriteAction
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
|
|
@ -85,7 +86,7 @@ class WriteTool(
|
|||
}
|
||||
|
||||
override suspend fun doExecute(args: Args): Result {
|
||||
val svc = project.getService(ProxyAISettingsService::class.java)
|
||||
val svc = project.service<ProxyAISettingsService>()
|
||||
if (svc.isPathIgnored(args.filePath)) {
|
||||
return Result.Error(
|
||||
filePath = args.filePath,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package ee.carlrobert.codegpt.settings
|
||||
|
||||
import ai.koog.agents.core.tools.Tool
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
|
|
@ -29,7 +30,9 @@ class ProxyAISettingsService(private val project: Project) {
|
|||
private val settingsFile: Path by lazy {
|
||||
Paths.get(project.basePath ?: "", ".proxyai", "settings.json")
|
||||
}
|
||||
private val store: ProxyAISettingsStore by lazy { ProxyAISettingsStore(settingsFile, json, logger) }
|
||||
private val store: ProxyAISettingsStore by lazy {
|
||||
ProxyAISettingsStore(settingsFile, json, logger)
|
||||
}
|
||||
private val cache = AtomicReference<CachedSettings?>(null)
|
||||
private val isWindows = System.getProperty("os.name")?.lowercase()?.contains("windows") == true
|
||||
|
||||
|
|
@ -55,12 +58,34 @@ class ProxyAISettingsService(private val project: Project) {
|
|||
updateSettings { it.copy(hooks = configuration) }
|
||||
}
|
||||
|
||||
fun getBashPermissions(): List<String> {
|
||||
return snapshot().settings.permissions.allow
|
||||
fun evaluateToolPermission(tool: Tool<*, *>, target: String): ToolPermissionPolicy.Decision {
|
||||
val settings = snapshot().settings
|
||||
val targets = permissionTargets(target)
|
||||
return ToolPermissionPolicy.evaluate(
|
||||
permissions = ToolPermissionPolicy.PermissionLists(
|
||||
allow = settings.permissions.allow,
|
||||
ask = settings.permissions.ask,
|
||||
deny = settings.permissions.deny
|
||||
),
|
||||
toolName = tool.name,
|
||||
targets = targets
|
||||
)
|
||||
}
|
||||
|
||||
fun getIgnorePatterns(): List<String> {
|
||||
return snapshot().settings.ignore
|
||||
fun isToolInvocationDenied(tool: Tool<*, *>, target: String): Boolean {
|
||||
return evaluateToolPermission(tool, target) == ToolPermissionPolicy.Decision.DENY
|
||||
}
|
||||
|
||||
fun isToolInvocationWhitelisted(tool: Tool<*, *>, target: String): Boolean {
|
||||
return evaluateToolPermission(tool, target) == ToolPermissionPolicy.Decision.ALLOW
|
||||
}
|
||||
|
||||
fun hasAllowRulesForTool(toolName: String): Boolean {
|
||||
val allows = snapshot().settings.permissions.allow
|
||||
return allows.any { rule ->
|
||||
val trimmed = rule.trim()
|
||||
trimmed == toolName || trimmed.startsWith("$toolName(")
|
||||
}
|
||||
}
|
||||
|
||||
fun isPathIgnored(path: String): Boolean {
|
||||
|
|
@ -71,6 +96,31 @@ class ProxyAISettingsService(private val project: Project) {
|
|||
return snapshot().ignoreMatcher.matches(path, basePath)
|
||||
}
|
||||
|
||||
private fun permissionTargets(target: String): List<String> {
|
||||
val normalized = try {
|
||||
Paths.get(target).normalize().toString().replace('\\', '/')
|
||||
} catch (_: Exception) {
|
||||
target.replace('\\', '/')
|
||||
}
|
||||
val fileName = normalized.substringAfterLast('/')
|
||||
val base = (project.basePath ?: "").replace('\\', '/').trimEnd('/')
|
||||
if (base.isBlank()) return listOf(normalized, target)
|
||||
|
||||
val rel = if (normalized.startsWith(base)) {
|
||||
normalized.removePrefix(base).removePrefix("/")
|
||||
} else null
|
||||
|
||||
return buildList {
|
||||
add(target)
|
||||
add(normalized)
|
||||
if (fileName.isNotBlank()) add(fileName)
|
||||
if (!rel.isNullOrBlank()) {
|
||||
add(rel)
|
||||
add("./$rel")
|
||||
}
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
private fun snapshot(): CachedSettings {
|
||||
val cached = cache.get()
|
||||
val lastModified = store.lastModified()
|
||||
|
|
@ -160,9 +210,9 @@ private class IgnoreMatcher(
|
|||
return compiled.any { (raw, rx) ->
|
||||
val normalized = if (isWindows) raw.lowercase() else raw
|
||||
simpleMatch(relToTest, normalized) ||
|
||||
simpleMatch(pathToTest, normalized) ||
|
||||
rx.matches(relToTest) ||
|
||||
rx.matches(pathToTest)
|
||||
simpleMatch(pathToTest, normalized) ||
|
||||
rx.matches(relToTest) ||
|
||||
rx.matches(pathToTest)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +224,7 @@ private class IgnoreMatcher(
|
|||
}
|
||||
return IgnoreMatcher(compiled, isWindows)
|
||||
}
|
||||
|
||||
private fun globToRegex(glob: String, ignoreCase: Boolean = false): Regex? {
|
||||
return try {
|
||||
var g = glob.trim()
|
||||
|
|
@ -194,6 +245,7 @@ private class IgnoreMatcher(
|
|||
sb.append("[^/]*")
|
||||
}
|
||||
}
|
||||
|
||||
'?' -> sb.append(".")
|
||||
'.', '(', ')', '+', '|', '^', '$', '@', '%' -> sb.append('\\').append(c)
|
||||
'[' -> sb.append("[")
|
||||
|
|
@ -207,7 +259,10 @@ private class IgnoreMatcher(
|
|||
}
|
||||
if (dirSuffix) sb.append("(/.*)?")
|
||||
sb.append('$')
|
||||
if (ignoreCase) Regex(sb.toString(), RegexOption.IGNORE_CASE) else Regex(sb.toString())
|
||||
if (ignoreCase) Regex(
|
||||
sb.toString(),
|
||||
RegexOption.IGNORE_CASE
|
||||
) else Regex(sb.toString())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
|
@ -255,27 +310,27 @@ data class ProxyAISettings(
|
|||
),
|
||||
permissions = Permissions(
|
||||
allow = listOf(
|
||||
"Bash(rg:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(tail:*)",
|
||||
"Bash(diff:*)",
|
||||
"Bash(du:*)",
|
||||
"Bash(file:*)",
|
||||
"Bash(sort:*)",
|
||||
"Bash(stat:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(uniq:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(whereis:*)",
|
||||
"Bash(which:*)",
|
||||
"Bash(less:*)",
|
||||
"Bash(more:*)",
|
||||
"Bash(rg *)",
|
||||
"Bash(grep *)",
|
||||
"Bash(find *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(sed *)",
|
||||
"Bash(rm *)",
|
||||
"Bash(cat *)",
|
||||
"Bash(head *)",
|
||||
"Bash(tail *)",
|
||||
"Bash(diff *)",
|
||||
"Bash(du *)",
|
||||
"Bash(file *)",
|
||||
"Bash(sort *)",
|
||||
"Bash(stat *)",
|
||||
"Bash(tree *)",
|
||||
"Bash(uniq *)",
|
||||
"Bash(wc *)",
|
||||
"Bash(whereis *)",
|
||||
"Bash(which *)",
|
||||
"Bash(less *)",
|
||||
"Bash(more *)",
|
||||
)
|
||||
),
|
||||
subagents = SubagentDefaults.defaults(),
|
||||
|
|
@ -286,7 +341,9 @@ data class ProxyAISettings(
|
|||
|
||||
@Serializable
|
||||
data class Permissions(
|
||||
val allow: List<String>
|
||||
val allow: List<String> = emptyList(),
|
||||
val ask: List<String> = emptyList(),
|
||||
val deny: List<String> = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
package ee.carlrobert.codegpt.settings
|
||||
|
||||
object ToolPermissionPolicy {
|
||||
|
||||
enum class Decision {
|
||||
DENY,
|
||||
ASK,
|
||||
ALLOW,
|
||||
NONE
|
||||
}
|
||||
|
||||
data class PermissionLists(
|
||||
val allow: List<String> = emptyList(),
|
||||
val ask: List<String> = emptyList(),
|
||||
val deny: List<String> = emptyList()
|
||||
)
|
||||
|
||||
fun evaluate(
|
||||
permissions: PermissionLists,
|
||||
toolName: String,
|
||||
targets: List<String>
|
||||
): Decision {
|
||||
if (firstMatch(permissions.deny, toolName, targets) != null) return Decision.DENY
|
||||
if (firstMatch(permissions.ask, toolName, targets) != null) return Decision.ASK
|
||||
if (firstMatch(permissions.allow, toolName, targets) != null) return Decision.ALLOW
|
||||
return Decision.NONE
|
||||
}
|
||||
|
||||
private fun parseRule(raw: String): PermissionRule? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val open = raw.indexOf('(')
|
||||
if (open < 0) {
|
||||
return PermissionRule(raw.trim(), null)
|
||||
}
|
||||
val close = raw.lastIndexOf(')')
|
||||
if (open !in 1..close) return null
|
||||
|
||||
val toolName = raw.substring(0, open).trim()
|
||||
val inner = raw.substring(open + 1, close).trim()
|
||||
if (toolName.isEmpty()) return null
|
||||
return PermissionRule(toolName, inner.ifEmpty { "*" })
|
||||
}
|
||||
|
||||
private fun firstMatch(rules: List<String>, toolName: String, targets: List<String>): PermissionRule? {
|
||||
val parsed = rules.mapNotNull { parseRule(it) }
|
||||
return parsed.firstOrNull { rule ->
|
||||
rule.toolName == toolName && targets.any { target -> matches(rule, target) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun matches(rule: PermissionRule, target: String): Boolean {
|
||||
val specifier = rule.specifier ?: return true
|
||||
if (specifier == "*") return true
|
||||
return if (specifier.contains('*')) {
|
||||
wildcardMatch(specifier, target)
|
||||
} else {
|
||||
target == specifier
|
||||
}
|
||||
}
|
||||
|
||||
private fun wildcardMatch(pattern: String, target: String): Boolean {
|
||||
val regexPattern = pattern
|
||||
.split('*')
|
||||
.joinToString(".*") { Regex.escape(it) }
|
||||
return Regex("^$regexPattern$").matches(target)
|
||||
}
|
||||
|
||||
private data class PermissionRule(val toolName: String, val specifier: String?)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package ee.carlrobert.codegpt.settings.hooks
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
|
||||
import org.slf4j.LoggerFactory
|
||||
|
|
@ -20,8 +21,7 @@ class HookManager(
|
|||
toolId: String? = null,
|
||||
sessionId: String? = null
|
||||
): List<HookExecutionResult> {
|
||||
val settingsService = project.getService(ProxyAISettingsService::class.java)
|
||||
val settings = settingsService.getSettings()
|
||||
val settings = project.service<ProxyAISettingsService>().getSettings()
|
||||
val configuration = settings.hooks ?: return emptyList()
|
||||
val hooks = configuration.hooksFor(event)
|
||||
val matchingHooks = hooks.filter { hook ->
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
package ee.carlrobert.codegpt.settings.hooks
|
||||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import ai.koog.agents.ext.tool.shell.ShellCommandConfirmation
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
||||
import com.intellij.openapi.components.service
|
||||
import ee.carlrobert.codegpt.agent.ToolRunContext
|
||||
import ee.carlrobert.codegpt.agent.tools.BashTool
|
||||
import ee.carlrobert.codegpt.agent.tools.EditTool
|
||||
import ee.carlrobert.codegpt.agent.tools.ReadTool
|
||||
import ee.carlrobert.codegpt.settings.hooks.HookManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import testsupport.IntegrationTest
|
||||
|
|
@ -15,7 +14,7 @@ import java.io.File
|
|||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.FileTime
|
||||
|
||||
class HooksIntegrationTest : IntegrationTest() {
|
||||
class HooksTest : IntegrationTest() {
|
||||
|
||||
fun testBeforeToolUseHookDeniesToolExecution() {
|
||||
val logFile =
|
||||
|
|
@ -28,10 +27,9 @@ class HooksIntegrationTest : IntegrationTest() {
|
|||
"""{"beforeToolUse":[{"command":".proxyai/hooks/${hookScript.name}"}]}"""
|
||||
writeSettings(settings)
|
||||
val tool = BashTool(
|
||||
project.basePath ?: "",
|
||||
project,
|
||||
{ ShellCommandConfirmation.Approved },
|
||||
"test-pretool-deny",
|
||||
project.service(),
|
||||
HookManager(project)
|
||||
)
|
||||
ToolRunContext.set("test-pretool-deny", "tool-pretool-deny")
|
||||
|
|
@ -64,10 +62,9 @@ class HooksIntegrationTest : IntegrationTest() {
|
|||
"""{"beforeToolUse":[{"command":".proxyai/hooks/${hookScript.name}"}]}"""
|
||||
writeSettings(settings)
|
||||
val tool = BashTool(
|
||||
project.basePath ?: "",
|
||||
project,
|
||||
{ ShellCommandConfirmation.Approved },
|
||||
"test",
|
||||
project.service(),
|
||||
HookManager(project)
|
||||
)
|
||||
ToolRunContext.set("test", "tool-1")
|
||||
|
|
@ -94,21 +91,21 @@ class HooksIntegrationTest : IntegrationTest() {
|
|||
).apply { parentFile.mkdirs() }
|
||||
val hookScript = writeHookScript(
|
||||
"posttool_update.sh",
|
||||
$$"""#!/usr/bin/env sh
|
||||
payload="$(cat)"
|
||||
echo "$payload" >> "$PWD/.proxyai/hooks/posttool_update.log"
|
||||
echo '{"updated_output":{"command":"echo ORIGINAL","exitCode":0,"output":"REWRITTEN_BY_AFTER_HOOK","bashId":null}}'
|
||||
exit 0
|
||||
"""
|
||||
$$"""
|
||||
#!/usr/bin/env sh
|
||||
payload="$(cat)"
|
||||
echo "$payload" >> "$PWD/.proxyai/hooks/posttool_update.log"
|
||||
echo '{"updated_output":{"command":"echo ORIGINAL","exitCode":0,"output":"REWRITTEN_BY_AFTER_HOOK","bashId":null}}'
|
||||
exit 0
|
||||
""".trimIndent()
|
||||
)
|
||||
val settings =
|
||||
"""{"afterToolUse":[{"command":".proxyai/hooks/${hookScript.name}"}]}"""
|
||||
writeSettings(settings)
|
||||
val tool = BashTool(
|
||||
project.basePath ?: "",
|
||||
project,
|
||||
{ ShellCommandConfirmation.Approved },
|
||||
"test",
|
||||
project.service(),
|
||||
HookManager(project)
|
||||
)
|
||||
ToolRunContext.set("test", "tool-1")
|
||||
|
|
@ -138,10 +135,9 @@ exit 0
|
|||
"""{"beforeShellExecution":[{"command":".proxyai/hooks/${hookScript.name}"}]}"""
|
||||
writeSettings(settings)
|
||||
val tool = BashTool(
|
||||
project.basePath ?: "",
|
||||
project,
|
||||
{ ShellCommandConfirmation.Approved },
|
||||
"test",
|
||||
project.service(),
|
||||
HookManager(project)
|
||||
)
|
||||
ToolRunContext.set("test", "tool-1")
|
||||
|
|
@ -156,6 +152,48 @@ exit 0
|
|||
assertThat(map["command"]).isEqualTo("echo test")
|
||||
}
|
||||
|
||||
fun testBeforeShellExecutionDeniesGradlewViaBash() {
|
||||
val logFile =
|
||||
File(project.basePath, ".proxyai/hooks/bash_gradlew.log").apply { parentFile.mkdirs() }
|
||||
val hookScript = writeHookScript(
|
||||
"block_bash_gradlew.sh",
|
||||
$$"""
|
||||
#!/usr/bin/env sh
|
||||
json="$(cat)"
|
||||
echo "$json" >> "$PWD/.proxyai/hooks/bash_gradlew.log"
|
||||
echo '{"reason":"Blocked bash gradlew"}'
|
||||
exit 2
|
||||
""".trimIndent()
|
||||
)
|
||||
val settings =
|
||||
"""{"beforeShellExecution":[{"command":".proxyai/hooks/${hookScript.name}","matcher":"gradlew"}]}"""
|
||||
writeSettings(settings)
|
||||
|
||||
val tool = BashTool(
|
||||
project,
|
||||
{ ShellCommandConfirmation.Approved },
|
||||
"test-bash-gradlew",
|
||||
HookManager(project)
|
||||
)
|
||||
ToolRunContext.set("test-bash-gradlew", "tool-bash-gradlew")
|
||||
|
||||
val blocked = runBlocking {
|
||||
tool.execute(BashTool.Args(command = "bash ./gradlew test", description = "test"))
|
||||
}
|
||||
assertThat(blocked.exitCode).isNull()
|
||||
assertThat(blocked.output).isEqualTo("Blocked bash gradlew")
|
||||
|
||||
val map = ObjectMapper().registerKotlinModule()
|
||||
.readValue(logFile.readText(), Map::class.java)
|
||||
assertThat(map["hook_event_name"]).isEqualTo("beforeShellExecution")
|
||||
assertThat(map["command"]).isEqualTo("bash ./gradlew test")
|
||||
|
||||
val allowed = runBlocking {
|
||||
tool.execute(BashTool.Args(command = "echo should_run", description = "test"))
|
||||
}
|
||||
assertThat(allowed.output).doesNotContain("Blocked bash gradlew")
|
||||
}
|
||||
|
||||
fun testAfterFileEditHookWritesLog() {
|
||||
val target = File(project.basePath, "hook_edit.txt").apply {
|
||||
parentFile.mkdirs()
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import ai.koog.agents.ext.tool.shell.ShellCommandConfirmation
|
||||
import ee.carlrobert.codegpt.agent.tools.BashTool
|
||||
import ee.carlrobert.codegpt.agent.tools.ReadTool
|
||||
import ee.carlrobert.codegpt.agent.tools.WriteTool
|
||||
import ee.carlrobert.codegpt.settings.hooks.HookManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import testsupport.IntegrationTest
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.FileTime
|
||||
|
||||
class IgnoreRulesTest : IntegrationTest() {
|
||||
|
||||
fun testReadBlockedByIgnoreRule() {
|
||||
val envFile = File(project.basePath, ".env").apply { writeText("SECRET=1") }
|
||||
writeSettings(ignoreEntries = listOf(".env"))
|
||||
|
||||
val result = runBlocking {
|
||||
ReadTool(project, HookManager(project), "ignore-test")
|
||||
.execute(ReadTool.Args(envFile.absolutePath))
|
||||
}
|
||||
|
||||
assertThat(result).isInstanceOf(ReadTool.Result.Error::class.java)
|
||||
val error = result as ReadTool.Result.Error
|
||||
assertThat(error.error).isEqualTo("Access to this path is blocked by .proxyai ignore rules")
|
||||
}
|
||||
|
||||
fun testWriteBlockedByIgnoreRule() {
|
||||
val pemFile = File(project.basePath, "certs/private.pem")
|
||||
writeSettings(ignoreEntries = listOf("**/*.pem"))
|
||||
|
||||
val result = runBlocking {
|
||||
WriteTool(project, HookManager(project))
|
||||
.execute(WriteTool.Args(pemFile.absolutePath, "PRIVATE KEY"))
|
||||
}
|
||||
|
||||
assertThat(result).isInstanceOf(WriteTool.Result.Error::class.java)
|
||||
val error = result as WriteTool.Result.Error
|
||||
assertThat(error.error).isEqualTo(".proxyai ignore rules block writing to this path")
|
||||
}
|
||||
|
||||
fun testBashBlockedByIgnoreRule() {
|
||||
val secretFile = File(project.basePath, "secrets/token.txt").apply {
|
||||
parentFile.mkdirs()
|
||||
writeText("token")
|
||||
}
|
||||
writeSettings(ignoreEntries = listOf("secrets/**"))
|
||||
|
||||
val tool = BashTool(
|
||||
project = project,
|
||||
confirmationHandler = { ShellCommandConfirmation.Approved },
|
||||
sessionId = "ignore-bash",
|
||||
hookManager = HookManager(project)
|
||||
)
|
||||
val result = runBlocking {
|
||||
tool.execute(
|
||||
BashTool.Args(
|
||||
command = "cat ${secretFile.absolutePath}",
|
||||
description = "cat"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
assertThat(result.exitCode).isNull()
|
||||
assertThat(result.output).isEqualTo("Command denied by policy: access to ignored files is blocked")
|
||||
}
|
||||
|
||||
fun testReadAllowedWhenPathNotIgnored() {
|
||||
val file = File(project.basePath, "src/ok.txt").apply {
|
||||
parentFile.mkdirs()
|
||||
writeText("ok")
|
||||
}
|
||||
writeSettings(ignoreEntries = listOf("secrets/**"))
|
||||
|
||||
val result = runBlocking {
|
||||
ReadTool(project, HookManager(project), "ignore-test")
|
||||
.execute(ReadTool.Args(file.absolutePath))
|
||||
}
|
||||
|
||||
assertThat(result).isInstanceOf(ReadTool.Result.Success::class.java)
|
||||
}
|
||||
|
||||
private fun writeSettings(ignoreEntries: List<String>): File {
|
||||
val ignoreJson = ignoreEntries.joinToString(",") { "\"$it\"" }
|
||||
val file = File(project.basePath, ".proxyai/settings.json")
|
||||
file.parentFile.mkdirs()
|
||||
file.writeText(
|
||||
"""{"ignore":[$ignoreJson],"permissions":{"allow":[],"ask":[],"deny":[]},"hooks":{}}"""
|
||||
)
|
||||
Files.setLastModifiedTime(
|
||||
file.toPath(),
|
||||
FileTime.fromMillis(System.currentTimeMillis() + 1000)
|
||||
)
|
||||
return file
|
||||
}
|
||||
}
|
||||
156
src/test/kotlin/ee/carlrobert/codegpt/agent/PermissionsTest.kt
Normal file
156
src/test/kotlin/ee/carlrobert/codegpt/agent/PermissionsTest.kt
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import ai.koog.agents.ext.tool.shell.ShellCommandConfirmation
|
||||
import ee.carlrobert.codegpt.agent.tools.BashTool
|
||||
import ee.carlrobert.codegpt.agent.tools.ReadTool
|
||||
import ee.carlrobert.codegpt.settings.hooks.HookManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import testsupport.IntegrationTest
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.FileTime
|
||||
|
||||
class PermissionsTest : IntegrationTest() {
|
||||
|
||||
fun testReadDeniedByDenyRuleMatchingFileName() {
|
||||
val file = File(project.basePath, "blocked-read.txt").apply { writeText("secret") }
|
||||
writeSettings(
|
||||
denyEntries = listOf("Read(blocked-read.txt)")
|
||||
)
|
||||
|
||||
val result = runBlocking {
|
||||
seedToolContext("perm-test")
|
||||
ReadTool(project, HookManager(project), "perm-test")
|
||||
.execute(ReadTool.Args(file.absolutePath))
|
||||
}
|
||||
|
||||
assertThat(result).isInstanceOf(ReadTool.Result.Error::class.java)
|
||||
val error = result as ReadTool.Result.Error
|
||||
assertThat(error.error).isEqualTo("Access denied by permissions.deny for Read")
|
||||
}
|
||||
|
||||
fun testReadDeniedWhenAllowRulesExistAndNoneMatch() {
|
||||
val file = File(project.basePath, "docs/notes.txt").apply {
|
||||
parentFile.mkdirs()
|
||||
writeText("notes")
|
||||
}
|
||||
writeSettings(
|
||||
allowEntries = listOf("Read(./src/**)")
|
||||
)
|
||||
|
||||
val result = runBlocking {
|
||||
seedToolContext("perm-test")
|
||||
ReadTool(project, HookManager(project), "perm-test")
|
||||
.execute(ReadTool.Args(file.absolutePath))
|
||||
}
|
||||
|
||||
assertThat(result).isInstanceOf(ReadTool.Result.Error::class.java)
|
||||
val error = result as ReadTool.Result.Error
|
||||
assertThat(error.error).isEqualTo("Access denied by permissions.allow for Read")
|
||||
}
|
||||
|
||||
fun testReadAllowedWhenAllowRuleMatchesPath() {
|
||||
val file = File(project.basePath, "src/main/kotlin/allowed.txt").apply {
|
||||
parentFile.mkdirs()
|
||||
writeText("ok")
|
||||
}
|
||||
writeSettings(
|
||||
allowEntries = listOf("Read(./src/**)")
|
||||
)
|
||||
|
||||
val result = runBlocking {
|
||||
seedToolContext("perm-test")
|
||||
ReadTool(project, HookManager(project), "perm-test")
|
||||
.execute(ReadTool.Args(file.absolutePath))
|
||||
}
|
||||
|
||||
assertThat(result).isInstanceOf(ReadTool.Result.Success::class.java)
|
||||
}
|
||||
|
||||
fun testBashDeniedByDenyRule() {
|
||||
writeSettings(
|
||||
denyEntries = listOf("Bash(git push *)")
|
||||
)
|
||||
|
||||
val tool = createBashTool("perm-bash") { ShellCommandConfirmation.Approved }
|
||||
val result = runBlocking {
|
||||
seedToolContext("perm-bash")
|
||||
tool.execute(BashTool.Args(command = "git push origin main", description = "push"))
|
||||
}
|
||||
|
||||
assertThat(result.exitCode).isNull()
|
||||
assertThat(result.output).isEqualTo("Access denied by permissions.deny for Bash")
|
||||
}
|
||||
|
||||
fun testBashAllowRuleBypassesConfirmationHandler() {
|
||||
writeSettings(
|
||||
allowEntries = listOf("Bash(echo *)")
|
||||
)
|
||||
|
||||
val tool = createBashTool("perm-bash-allow") {
|
||||
ShellCommandConfirmation.Denied("should not be called when allow matches")
|
||||
}
|
||||
val result = runBlocking {
|
||||
seedToolContext("perm-bash-allow")
|
||||
tool.execute(BashTool.Args(command = "echo OK", description = "echo"))
|
||||
}
|
||||
|
||||
assertThat(result.exitCode).isEqualTo(0)
|
||||
assertThat(result.output.trim()).isEqualTo("OK")
|
||||
}
|
||||
|
||||
fun testBashFallsBackToConfirmationWhenNoAllowMatch() {
|
||||
writeSettings(
|
||||
allowEntries = listOf("Bash(echo *)")
|
||||
)
|
||||
|
||||
val tool = createBashTool("perm-bash-deny") {
|
||||
ShellCommandConfirmation.Denied("explicit deny")
|
||||
}
|
||||
val result = runBlocking {
|
||||
seedToolContext("perm-bash-deny")
|
||||
tool.execute(BashTool.Args(command = "git status", description = "status"))
|
||||
}
|
||||
|
||||
assertThat(result.exitCode).isNull()
|
||||
assertThat(result.output).contains("explicit deny")
|
||||
}
|
||||
|
||||
private fun createBashTool(
|
||||
sessionId: String,
|
||||
confirmation: suspend (BashTool.Args) -> ShellCommandConfirmation
|
||||
): BashTool {
|
||||
return BashTool(
|
||||
project = project,
|
||||
confirmationHandler = confirmation,
|
||||
sessionId = sessionId,
|
||||
hookManager = HookManager(project)
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeSettings(
|
||||
allowEntries: List<String> = emptyList(),
|
||||
askEntries: List<String> = emptyList(),
|
||||
denyEntries: List<String> = emptyList()
|
||||
): File {
|
||||
val allowJson = allowEntries.joinToString(",") { "\"$it\"" }
|
||||
val askJson = askEntries.joinToString(",") { "\"$it\"" }
|
||||
val denyJson = denyEntries.joinToString(",") { "\"$it\"" }
|
||||
val file = File(project.basePath, ".proxyai/settings.json")
|
||||
file.parentFile.mkdirs()
|
||||
file.writeText("""{"ignore":[],"permissions":{"allow":[$allowJson],"ask":[$askJson],"deny":[$denyJson]},"hooks":{}}""")
|
||||
Files.setLastModifiedTime(
|
||||
file.toPath(),
|
||||
FileTime.fromMillis(System.currentTimeMillis() + 1000)
|
||||
)
|
||||
return file
|
||||
}
|
||||
|
||||
private fun seedToolContext(sessionId: String) {
|
||||
ToolRunContext.set(
|
||||
sessionId = sessionId,
|
||||
toolId = "test-tool-$sessionId"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
package ee.carlrobert.codegpt.agent.rollback
|
||||
package ee.carlrobert.codegpt.agent
|
||||
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import ee.carlrobert.codegpt.agent.tools.EditTool
|
||||
import ee.carlrobert.codegpt.agent.rollback.ChangeKind
|
||||
import ee.carlrobert.codegpt.agent.rollback.RollbackService
|
||||
import ee.carlrobert.codegpt.agent.tools.WriteTool
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import testsupport.IntegrationTest
|
||||
|
|
@ -71,4 +72,4 @@ class RollbackServiceTrackingTest : IntegrationTest() {
|
|||
.extracting("beforeText", "afterText")
|
||||
.containsExactly("before", "after")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue