From 8d0d87b37b92a62c77d8201242d8e703f86cbc10 Mon Sep 17 00:00:00 2001 From: skobeltsyn Date: Mon, 1 Jun 2026 21:27:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(#2884):=20ToolCapabilityExtractor=20?= =?UTF-8?q?=E2=80=94=20static=20capability=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epic #2882 (Pillar 1, static layer), TDD. New ToolCapabilityExtractor in agents-kt-detekt: classifies what a tool's executor body actually does (FS_READ / FS_WRITE / NETWORK / ENVIRONMENT / EXEC) by walking call expressions and matching callee names. The reusable input the #2887 comparator checks against the declared ToolPolicy. - extract(scope: KtElement): Set — collects call expressions under a scope (the executor lambda) and classifies by callee name. - Conservative + syntactic by design (no FQN resolution); reflection/aliasing/ transitive state are Pillar-3 residual (documented). Over-classification only widens review for the comparator, never hides authority. - Not wired into the project's own detekt — it's infrastructure for #2887, not a standalone codebase rule. Tests (9, TDD RED->GREEN, via detekt-test compileContentForTest): fs-write, fs-read, Files.write/readAllBytes by nio names, network, environment, exec (ProcessBuilder + Runtime.exec), pure-compute = empty, bare File handle = empty, multi-capability body. CHANGELOG. Full ./gradlew build green. --- CHANGELOG.md | 10 +++ .../detekt/ToolCapabilityExtractor.kt | 65 +++++++++++++++++++ .../detekt/ToolCapabilityExtractorTest.kt | 59 +++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 agents-kt-detekt/src/main/kotlin/agents_engine/detekt/ToolCapabilityExtractor.kt create mode 100644 agents-kt-detekt/src/test/kotlin/agents_engine/detekt/ToolCapabilityExtractorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index bfcef3d..b6c4c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to Agents.KT are documented here. The format follows [Keep a ## [Unreleased] +### Added — `ToolCapabilityExtractor`: static capability classification (#2884, epic #2882) + +- New `ToolCapabilityExtractor` in `agents-kt-detekt` statically classifies what a tool's executor + body actually does — `FS_READ` / `FS_WRITE` / `NETWORK` / `ENVIRONMENT` / `EXEC` — by walking its + call expressions and matching callee names (`writeText`/`Files.write` → write, `readText`/ + `readAllBytes` → read, `URL`/`openConnection` → network, `getenv` → env, `ProcessBuilder`/`exec` → + exec). The reusable input the upcoming `ToolPolicy`↔capability comparator (#2887) checks against the + *declared* policy. Syntactic by design (callee-name match, no FQN resolution) and intentionally + conservative — reflection / aliasing / transitive state are Pillar-3 residual. + ### Added — `ToolAuditLedger`: tamper-evident, Merkle-chained tool-action log (#2886, epic #2882) - New `ToolAuditLedger` (in `agents-kt-observability`, sibling to `JsonlAuditExporter`) — an diff --git a/agents-kt-detekt/src/main/kotlin/agents_engine/detekt/ToolCapabilityExtractor.kt b/agents-kt-detekt/src/main/kotlin/agents_engine/detekt/ToolCapabilityExtractor.kt new file mode 100644 index 0000000..f98bc14 --- /dev/null +++ b/agents-kt-detekt/src/main/kotlin/agents_engine/detekt/ToolCapabilityExtractor.kt @@ -0,0 +1,65 @@ +package agents_engine.detekt + +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType + +/** A capability a tool's executor body exercises, classified statically from its call sites. */ +enum class ToolCapability { FS_READ, FS_WRITE, NETWORK, ENVIRONMENT, EXEC } + +/** + * #2884 (epic #2882, Pillar 1 — static layer). Classifies what a tool's executor body + * actually does — filesystem read/write, network, environment, process exec — by walking + * its call expressions and matching callee names. The reusable input the #2887 comparator + * checks against the declared [agents_engine.core.ToolPolicy]. + * + * **Honest limit (the epic's attack matrix marks these ⚠️):** this is **syntactic** — it + * matches the call's callee name, not a resolved FQN, so it can't see reflection + * (`Class.forName`), aliasing, or transitive library state changes. It is also intentionally + * **conservative**: where a name is ambiguous it errs toward reporting the capability, since + * for the comparator an over-report only widens review, never hides authority. Those residual + * risks are covered by Pillar 3 (process isolation). + */ +object ToolCapabilityExtractor { + + /** Capabilities exercised by call expressions anywhere under [scope] (e.g. an executor lambda). */ + fun extract(scope: KtElement): Set { + val capabilities = linkedSetOf() + scope.collectDescendantsOfType().forEach { call -> + classify(call.calleeExpression?.text?.substringAfterLast('.'))?.let { capabilities += it } + } + return capabilities + } + + private fun classify(callee: String?): ToolCapability? = when (callee) { + null -> null + in FS_WRITE -> ToolCapability.FS_WRITE + in FS_READ -> ToolCapability.FS_READ + in NETWORK -> ToolCapability.NETWORK + in ENVIRONMENT -> ToolCapability.ENVIRONMENT + in EXEC -> ToolCapability.EXEC + else -> null + } + + private val FS_WRITE = setOf( + "writeText", "writeBytes", "appendText", "appendBytes", "write", "createNewFile", + "createFile", "createDirectory", "createDirectories", "delete", "deleteIfExists", + "mkdir", "mkdirs", "move", "copy", "newOutputStream", "bufferedWriter", "printWriter", + "FileOutputStream", "FileWriter", "RandomAccessFile", + ) + + private val FS_READ = setOf( + "readText", "readBytes", "readLines", "readString", "readAllLines", "readAllBytes", + "newInputStream", "bufferedReader", "listFiles", "walk", + "FileInputStream", "FileReader", + ) + + private val NETWORK = setOf( + "URL", "HttpURLConnection", "Socket", "ServerSocket", "openConnection", "openStream", + "HttpClient", + ) + + private val ENVIRONMENT = setOf("getenv") + + private val EXEC = setOf("ProcessBuilder", "exec", "getRuntime") +} diff --git a/agents-kt-detekt/src/test/kotlin/agents_engine/detekt/ToolCapabilityExtractorTest.kt b/agents-kt-detekt/src/test/kotlin/agents_engine/detekt/ToolCapabilityExtractorTest.kt new file mode 100644 index 0000000..9d7fbdf --- /dev/null +++ b/agents-kt-detekt/src/test/kotlin/agents_engine/detekt/ToolCapabilityExtractorTest.kt @@ -0,0 +1,59 @@ +package agents_engine.detekt + +import io.github.detekt.test.utils.compileContentForTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * #2884 (epic #2882) — `ToolCapabilityExtractor` statically classifies what a tool's + * executor body actually does (fs read/write, network, env, exec) by walking its call + * expressions. The reusable input the #2887 comparator checks against the declared + * `ToolPolicy`. Syntactic by design (callee-name match) — documented residual risk. + */ +class ToolCapabilityExtractorTest { + + private fun caps(code: String): Set = + ToolCapabilityExtractor.extract(compileContentForTest(code)) + + @Test fun `detects a filesystem write`() { + assertEquals(setOf(ToolCapability.FS_WRITE), caps("""fun f() { java.io.File("/x").writeText("y") }""")) + } + + @Test fun `detects a filesystem read`() { + assertEquals(setOf(ToolCapability.FS_READ), caps("""fun f() { java.io.File("/x").readText() }""")) + } + + @Test fun `detects Files write and read by their nio names`() { + assertTrue(ToolCapability.FS_WRITE in caps("""fun f() { java.nio.file.Files.write(p, b) }""")) + assertTrue(ToolCapability.FS_READ in caps("""fun f() { java.nio.file.Files.readAllBytes(p) }""")) + } + + @Test fun `detects network`() { + assertEquals(setOf(ToolCapability.NETWORK), caps("""fun f() { java.net.URL("http://x").openConnection() }""")) + } + + @Test fun `detects environment access`() { + assertEquals(setOf(ToolCapability.ENVIRONMENT), caps("""fun f() { System.getenv("HOME") }""")) + } + + @Test fun `detects process exec`() { + assertEquals(setOf(ToolCapability.EXEC), caps("""fun f() { ProcessBuilder("sh").start() }""")) + assertTrue(ToolCapability.EXEC in caps("""fun f() { Runtime.getRuntime().exec("ls") }""")) + } + + @Test fun `a pure-compute body has no capabilities`() { + assertTrue(caps("""fun f() { val x = 1 + 2; println(x) }""").isEmpty()) + } + + @Test fun `a bare File handle without an io call is not a capability`() { + // Constructing a File path is not, by itself, a read or a write. + assertTrue(caps("""fun f() { val p = java.io.File("/x"); p.name }""").isEmpty()) + } + + @Test fun `combines multiple capabilities from one body`() { + val c = caps("""fun f() { java.io.File("/x").writeText(System.getenv("Y") ?: "") }""") + assertTrue(ToolCapability.FS_WRITE in c) + assertTrue(ToolCapability.ENVIRONMENT in c) + } +}