diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentFactory.kt index e4b87bc3..65fb1281 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentFactory.kt @@ -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(), hookManager = hookManager ) ) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/ProxyAIAgent.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/ProxyAIAgent.kt index ffb70f72..d06f736e 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/ProxyAIAgent.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/ProxyAIAgent.kt @@ -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(), hookManager = hookManager ) ) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/rollback/RollbackService.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/rollback/RollbackService.kt index 1af8d3ee..72a18e02 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/rollback/RollbackService.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/rollback/RollbackService.kt @@ -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() private val snapshots = ConcurrentHashMap() - 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().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().isPathIgnored(path)) return false val ioFile = File(path) if (!ioFile.exists()) return false diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/BaseTool.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/BaseTool.kt index 58ddff86..fb9aba0a 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/BaseTool.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/BaseTool.kt @@ -26,7 +26,7 @@ abstract class BaseTool( resultSerializer: kotlinx.serialization.KSerializer, 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, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/BashTool.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/BashTool.kt index 41acb0a0..a7d9f076 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/BashTool.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/BashTool.kt @@ -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( - 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. - Working directory: $$workingDirectory + Working directory: $${project.basePath ?: ""} 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() + .isToolInvocationWhitelisted(this, args.command) } - override suspend fun doExecute(args: Args): Result { val toolId = ToolRunContext.getToolId(sessionId) + if (project.service() + .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() + return paths.any { candidate -> + settingsService.isPathIgnored(toAbsolute(candidate)) + } } private fun tokenize(s: String): List { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/EditTool.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/EditTool.kt index a1c5ec36..b7f9c17c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/EditTool.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/EditTool.kt @@ -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() if (svc.isPathIgnored(args.filePath)) { return Result.Error( filePath = args.filePath, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/IntelliJSearchTool.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/IntelliJSearchTool.kt index 83490303..02474601 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/IntelliJSearchTool.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/IntelliJSearchTool.kt @@ -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() if (vf?.path != null && svc.isPathIgnored(vf.path)) continue results.add( diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/ReadTool.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/ReadTool.kt index 3d14ac3a..313a7b51 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/ReadTool.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/ReadTool.kt @@ -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() + 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" diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/WriteTool.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/WriteTool.kt index 6c6d9fde..87f16a41 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/WriteTool.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/tools/WriteTool.kt @@ -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() if (svc.isPathIgnored(args.filePath)) { return Result.Error( filePath = args.filePath, diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/ProxyAISettingsService.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/ProxyAISettingsService.kt index a06d1e4b..6a23e4f2 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/ProxyAISettingsService.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/ProxyAISettingsService.kt @@ -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(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 { - 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 { - 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 { + 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 + val allow: List = emptyList(), + val ask: List = emptyList(), + val deny: List = emptyList() ) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/ToolPermissionPolicy.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/ToolPermissionPolicy.kt new file mode 100644 index 00000000..bf14fda5 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/ToolPermissionPolicy.kt @@ -0,0 +1,71 @@ +package ee.carlrobert.codegpt.settings + +object ToolPermissionPolicy { + + enum class Decision { + DENY, + ASK, + ALLOW, + NONE + } + + data class PermissionLists( + val allow: List = emptyList(), + val ask: List = emptyList(), + val deny: List = emptyList() + ) + + fun evaluate( + permissions: PermissionLists, + toolName: String, + targets: List + ): 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, toolName: String, targets: List): 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?) +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/hooks/HookManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/hooks/HookManager.kt index aaacca21..0968f9d8 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/hooks/HookManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/hooks/HookManager.kt @@ -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 { - val settingsService = project.getService(ProxyAISettingsService::class.java) - val settings = settingsService.getSettings() + val settings = project.service().getSettings() val configuration = settings.hooks ?: return emptyList() val hooks = configuration.hooksFor(event) val matchingHooks = hooks.filter { hook -> diff --git a/src/test/kotlin/ee/carlrobert/codegpt/settings/hooks/HooksIntegrationTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/agent/HooksTest.kt similarity index 81% rename from src/test/kotlin/ee/carlrobert/codegpt/settings/hooks/HooksIntegrationTest.kt rename to src/test/kotlin/ee/carlrobert/codegpt/agent/HooksTest.kt index 0270f636..7d9d5ca3 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/settings/hooks/HooksIntegrationTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/agent/HooksTest.kt @@ -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() diff --git a/src/test/kotlin/ee/carlrobert/codegpt/agent/IgnoreRulesTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/agent/IgnoreRulesTest.kt new file mode 100644 index 00000000..c0a6fec5 --- /dev/null +++ b/src/test/kotlin/ee/carlrobert/codegpt/agent/IgnoreRulesTest.kt @@ -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): 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 + } +} diff --git a/src/test/kotlin/ee/carlrobert/codegpt/agent/PermissionsTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/agent/PermissionsTest.kt new file mode 100644 index 00000000..084e1f08 --- /dev/null +++ b/src/test/kotlin/ee/carlrobert/codegpt/agent/PermissionsTest.kt @@ -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 = emptyList(), + askEntries: List = emptyList(), + denyEntries: List = 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" + ) + } +} diff --git a/src/test/kotlin/ee/carlrobert/codegpt/agent/rollback/RollbackServiceTrackingTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/agent/RollbackServiceTrackingTest.kt similarity index 94% rename from src/test/kotlin/ee/carlrobert/codegpt/agent/rollback/RollbackServiceTrackingTest.kt rename to src/test/kotlin/ee/carlrobert/codegpt/agent/RollbackServiceTrackingTest.kt index e6cef200..a14c408f 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/agent/rollback/RollbackServiceTrackingTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/agent/RollbackServiceTrackingTest.kt @@ -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") } -} +} \ No newline at end of file