A month ago, in a past "Thursdays with Koog" issue, I described Koog's framework for tools. Today, I'd like to talk a bit about how Knosh works with tools, in particular how things get secured.
Knosh sticks to Koog's tools-as-classes pattern, and so it has a bunch of SimpleTool
subclasses like GetWorkingDirectoryTool
:
package com.commonsware.knosh.tools
import ai.koog.agents.core.tools.SimpleTool
import ai.koog.serialization.typeToken
import com.commonsware.knosh.common.RuntimeContext
import kotlinx.serialization.Serializable
// Raw string is exempt from MaxLineLength per project Detekt config (excludeRawStrings: true)
private val DESCRIPTION =
"""
You MUST use this tool to get the current working directory. You do not have access to a shell, Bash, or any command-line tools — do not guess or assume the working directory.
Usage:
- Takes no arguments.
- The returned path is absolute and can be used to resolve relative paths or to
understand the root from which other tools operate.
"""
.trimIndent()
/** Tool that returns the current working directory as an absolute filesystem path string. Takes no meaningful input. */
public class GetWorkingDirectoryTool(private val context: RuntimeContext) :
SimpleTool<GetWorkingDirectoryTool.Args>(
argsType = typeToken<Args>(),
name = "get-working-directory",
description = DESCRIPTION,
) {
/** Arguments for [GetWorkingDirectoryTool]. There are no meaningful arguments; pass an empty object. */
@Serializable public class Args
/**
* Returns the current working directory as an absolute filesystem path.
*
* @param args unused
* @return the absolute path of the current working directory
*/
override suspend fun execute(args: Args): String = context.workingDir.toString()
}
(from Knosh 0.3.0)
KnoshToolSet
has a list of all of those tools:
private val tools: List<Tool<*, *>> =
listOf(
CreateDirectoryTool(dispatcher, context),
DeleteFileTool(dispatcher, context),
FileInfoTool(dispatcher, context),
GetWorkingDirectoryTool(context),
GlobTool(dispatcher, context),
GrepTool(dispatcher, context),
ListDirectoryTreeTool(dispatcher, context),
MoveContentTool(dispatcher, context),
TextEditTool(dispatcher, context),
TextReadTool(dispatcher, context),
TextWriteTool(dispatcher, context),
WebFetchTool(httpClient),
)
Here, dispatcher
is a CoroutineDispatcher
for doing work asynchronously, and context
is a RuntimeContext
that provides access to things that need to be overridden in tests, such as environment variables and the filesystem.
However, not all agents need all tools. If you have an agent that is solely for code reviews, you might not want it to have the ability to write to files, delete files, etc. Hence, in the frontmatter for an agent definition, you can enable and disable tools in a permission
structure:
permission:
text-write: "deny"
web-fetch: "allow"
Each tool has its unique identifier (e.g., text-write
maps to TextWriteTool
). By default, tools are allowed, but those an agent flags as deny
get removed from the list.
OpenCode — whose permission system Knosh adapts — also supports ask
, which prompts the user at the time of tool use whether to allow it. Knosh is a one-shot coding agent, designed for non-interactive use, so it does not support ask
.
However, tools that manipulate the filesystem need more fine-grained access controls than a top-level allow/deny flag. By default, files inside of the current working directory can be accessed, while files elsewhere cannot. But, you can further constrain that if desired:
permission:
text-edit:
"*": "deny"
"*.kt": "allow"
"*.kts": "allow"
"/Users/alice/projects/my-app/*": "allow"
Inside Knosh, since Koog's Tool
is an abstract class
, we can use the decorator pattern. PermissionCheckDecorator wraps a Knosh tool and enforces permissions before allowing the tool itself to its job:
/**
* A [Tool] decorator that enforces both agent-level and command-level permissions and logs each tool call.
*
* Wraps [wrapped] transparently, forwarding all [execute] calls after permission checks pass. On each successful
* execution, emits an INFO log event with the tool name as the message and `args`/` result` attributes. When
* [logFullToolCalls] is `false`, long content in args is redacted via [LoggableContent.logContent] and result strings
* that are longer than 200 characters or contain a newline are truncated: the text up to the first newline or first 80
* characters (whichever is shorter) is kept, followed by `<redacted: N chars>`.
*
* Enforces a dual-gate permission model: both the agent's permissions and the command's permissions must allow a tool
* invocation for it to proceed. First, [agentConfig]'s permissions are checked. Then, [commandPermissions] and
* [commandExternalDirectories] are checked independently. Either gate may throw [ToolException.ValidationFailure] via
* [fail] if a permission is denied.
*/
internal class PermissionCheckDecorator<A, B>(
private val wrapped: Tool<A, B>,
internal val agentConfig: AgentConfig,
private val commandPermissions: Map<String, List<ToolPermission>> = emptyMap(),
private val commandExternalDirectories: Map<String, Boolean> = emptyMap(),
private val logFullToolCalls: Boolean = false,
private val workingDir: Path = System.getProperty("user.dir").toPath(),
private val userHome: Path = System.getProperty("user.home").toPath(),
) : Tool<A, B>(argsType = wrapped.argsType, resultType = wrapped.resultType, descriptor = wrapped.descriptor) {
// whole lotta code
}
This allows the whole permission system to be abstracted out, tested in isolation, etc.
This works nicely for tools that are known in advance. Things get a bit interesting for tools that are not known in advance, notably MCP servers. In next week's "Thursdays with Koog", I'll talk about the soon-to-be-released Knosh 0.4.0
, what MCP is, and how Knosh supports MCP.