feat: ignore and permissions

This commit is contained in:
Carl-Robert Linnupuu 2026-02-03 01:53:38 +00:00
parent a7864d16b1
commit 3cd9f18d4a
16 changed files with 529 additions and 92 deletions

View file

@ -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
)
)

View file

@ -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
)
)

View file

@ -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

View file

@ -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>,

View file

@ -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> {

View file

@ -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,

View file

@ -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(

View file

@ -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"

View file

@ -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,

View file

@ -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()
)
}

View file

@ -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?)
}

View file

@ -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 ->

View file

@ -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()

View file

@ -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
}
}

View 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"
)
}
}

View file

@ -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")
}
}
}