{"slug": "thursdays-with-koog-permissions", "title": "Thursdays with Koog: Permissions", "summary": "Knosh 0.3.0, a one-shot coding agent built on the Koog framework, implements a permission system that allows agents to deny specific tools by their unique identifiers in frontmatter, with filesystem-manipulating tools requiring more fine-grained access controls. The system, adapted from OpenCode, defaults to allowing all tools but supports a 'deny' flag, while omitting OpenCode's 'ask' prompt because Knosh is non-interactive.", "body_md": "A month ago, in a past \"Thursdays with Koog\" issue, I described [Koog's](https://docs.koog.ai) framework for [tools](https://pac.commonsware.com/archive/thursdays-with-koog-tools/). Today, I'd like to talk a bit about how [Knosh](https://knosh.commonsware.com) works with tools, in particular how things get secured.\n\nKnosh sticks to Koog's tools-as-classes pattern, and so it has a bunch of `SimpleTool`\n\nsubclasses like `GetWorkingDirectoryTool`\n\n:\n\n``` python\npackage com.commonsware.knosh.tools\n\nimport ai.koog.agents.core.tools.SimpleTool\nimport ai.koog.serialization.typeToken\nimport com.commonsware.knosh.common.RuntimeContext\nimport kotlinx.serialization.Serializable\n\n// Raw string is exempt from MaxLineLength per project Detekt config (excludeRawStrings: true)\nprivate val DESCRIPTION =\n  \"\"\"\n  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.\n\n  Usage:\n  - Takes no arguments.\n  - The returned path is absolute and can be used to resolve relative paths or to\n    understand the root from which other tools operate.\n  \"\"\"\n    .trimIndent()\n\n/** Tool that returns the current working directory as an absolute filesystem path string. Takes no meaningful input. */\npublic class GetWorkingDirectoryTool(private val context: RuntimeContext) :\n  SimpleTool<GetWorkingDirectoryTool.Args>(\n    argsType = typeToken<Args>(),\n    name = \"get-working-directory\",\n    description = DESCRIPTION,\n  ) {\n  /** Arguments for [GetWorkingDirectoryTool]. There are no meaningful arguments; pass an empty object. */\n  @Serializable public class Args\n\n  /**\n   * Returns the current working directory as an absolute filesystem path.\n   *\n   * @param args unused\n   * @return the absolute path of the current working directory\n   */\n  override suspend fun execute(args: Args): String = context.workingDir.toString()\n}\n```\n\n(from [Knosh 0.3.0](https://codeberg.org/commonsguy/knosh/src/tag/0.3.0/lib/knosh-tools/src/main/kotlin/com/commonsware/knosh/tools/GetWorkingDirectoryTool.kt#))\n\n`KnoshToolSet`\n\nhas [a list of all of those tools](https://codeberg.org/commonsguy/knosh/src/tag/0.3.0/lib/knosh-agents/src/main/kotlin/com/commonsware/knosh/agents/KnoshToolSet.kt#L47-L61):\n\n```\n  private val tools: List<Tool<*, *>> =\n    listOf(\n      CreateDirectoryTool(dispatcher, context),\n      DeleteFileTool(dispatcher, context),\n      FileInfoTool(dispatcher, context),\n      GetWorkingDirectoryTool(context),\n      GlobTool(dispatcher, context),\n      GrepTool(dispatcher, context),\n      ListDirectoryTreeTool(dispatcher, context),\n      MoveContentTool(dispatcher, context),\n      TextEditTool(dispatcher, context),\n      TextReadTool(dispatcher, context),\n      TextWriteTool(dispatcher, context),\n      WebFetchTool(httpClient),\n    )\n```\n\nHere, `dispatcher`\n\nis a `CoroutineDispatcher`\n\nfor doing work asynchronously, and `context`\n\nis a `RuntimeContext`\n\nthat provides access to things that need to be overridden in tests, such as environment variables and the filesystem.\n\nHowever, 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`\n\nstructure:\n\n```\npermission:\n  text-write: \"deny\"\n  web-fetch: \"allow\"\n```\n\nEach tool has its unique identifier (e.g., `text-write`\n\nmaps to `TextWriteTool`\n\n). By default, tools are allowed, but those an agent flags as `deny`\n\nget removed from the list.\n\nOpenCode — whose permission system Knosh adapts — also supports `ask`\n\n, 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`\n\n.\n\nHowever, 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:\n\n```\npermission:\n  text-edit:\n    \"*\": \"deny\"\n    \"*.kt\": \"allow\"\n    \"*.kts\": \"allow\"\n    \"/Users/alice/projects/my-app/*\": \"allow\"\n```\n\nInside Knosh, since Koog's `Tool`\n\nis an `abstract class`\n\n, we can use the decorator pattern. [ PermissionCheckDecorator](https://codeberg.org/commonsguy/knosh/src/tag/0.3.0/lib/knosh-agents/src/main/kotlin/com/commonsware/knosh/agents/PermissionCheckDecorator.kt) wraps a Knosh tool and enforces permissions before allowing the tool itself to its job:\n\n```\n/**\n * A [Tool] decorator that enforces both agent-level and command-level permissions and logs each tool call.\n *\n * Wraps [wrapped] transparently, forwarding all [execute] calls after permission checks pass. On each successful\n * execution, emits an INFO log event with the tool name as the message and `args`/` result` attributes. When\n * [logFullToolCalls] is `false`, long content in args is redacted via [LoggableContent.logContent] and result strings\n * that are longer than 200 characters or contain a newline are truncated: the text up to the first newline or first 80\n * characters (whichever is shorter) is kept, followed by `<redacted: N chars>`.\n *\n * Enforces a dual-gate permission model: both the agent's permissions and the command's permissions must allow a tool\n * invocation for it to proceed. First, [agentConfig]'s permissions are checked. Then, [commandPermissions] and\n * [commandExternalDirectories] are checked independently. Either gate may throw [ToolException.ValidationFailure] via\n * [fail] if a permission is denied.\n */\ninternal class PermissionCheckDecorator<A, B>(\n  private val wrapped: Tool<A, B>,\n  internal val agentConfig: AgentConfig,\n  private val commandPermissions: Map<String, List<ToolPermission>> = emptyMap(),\n  private val commandExternalDirectories: Map<String, Boolean> = emptyMap(),\n  private val logFullToolCalls: Boolean = false,\n  private val workingDir: Path = System.getProperty(\"user.dir\").toPath(),\n  private val userHome: Path = System.getProperty(\"user.home\").toPath(),\n) : Tool<A, B>(argsType = wrapped.argsType, resultType = wrapped.resultType, descriptor = wrapped.descriptor) {\n    // whole lotta code\n}\n```\n\nThis allows the whole permission system to be abstracted out, tested in isolation, etc.\n\nThis 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`\n\n, what MCP is, and how Knosh supports MCP.", "url": "https://wpnews.pro/news/thursdays-with-koog-permissions", "canonical_source": "https://pac.commonsware.com/archive/thursdays-with-koog-permissions/", "published_at": "2026-08-27 13:00:00+00:00", "updated_at": "2026-08-27 13:22:33.738752+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "ai-products"], "entities": ["Knosh", "Koog", "OpenCode", "GetWorkingDirectoryTool", "KnoshToolSet", "RuntimeContext"], "alternates": {"html": "https://wpnews.pro/news/thursdays-with-koog-permissions", "markdown": "https://wpnews.pro/news/thursdays-with-koog-permissions.md", "text": "https://wpnews.pro/news/thursdays-with-koog-permissions.txt", "jsonld": "https://wpnews.pro/news/thursdays-with-koog-permissions.jsonld"}}