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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ToolCapability> {
val capabilities = linkedSetOf<ToolCapability>()
scope.collectDescendantsOfType<KtCallExpression>().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")
}
Original file line number Diff line number Diff line change
@@ -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<ToolCapability> =
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)
}
}
Loading