FORM NOT VOID, MIND NO CORE

Chapter 4: Tool System and Permission Model

2026.08.10

Tools are the agent's interface with the world -- granularity determines what the agent can and cannot do, and when it needs to ask.


Pattern Layer

4.1 The Design Philosophy of Tool Granularity

Agent tool set design faces a classic granularity dilemma: too coarse or too fine?

The extreme of too coarse: one tool does everything. For example, a single execute tool that accepts a natural language description, and the agent decides whether to read a file or run a command. The advantage of this design is a minimal tool set (the LLM can never select the wrong tool), but the cost is that permissions cannot be finely subdivided -- you cannot grant different permission levels for "reading a file" and "running a command." Moreover, the LLM may make mistakes in the "natural language description -> concrete operation" translation -- you say "read this file," it interprets it as "execute this command."

The extreme of too fine: one tool per operation. read_file, read_file_with_encoding, read_file_chunk, read_file_lines... each tool has its own signature. The LLM must remember the exact names and parameters of dozens of tools. The probability of selecting the wrong tool increases with the number of tools.

CodeCoder chose a boundary of 26 tools. This number was not derived from theoretical reasoning but from engineering practice: starting with 10 tools, each time encountering a fuzzy scenario of "which tool should handle this operation," a new tool was added or existing tool boundaries were adjusted, eventually converging on 26.

The 26 tools can be divided into six categories:

CategoryToolsPermission Level
File Operationsread_file, write_file, diffNone / Ask
Searchglob, grepNone
Executionrun_commandAsk
GitcommitAsk
Flow Controlplan, milestone, review, reasonNone / Ask
Self-Modificationgenerate_skill, generate_prompt, generate_capability, use_skill, run_capabilityNone / Ask
Interactionask_user, confirm, memoryNone
Sub-AgentagentNone

The permission key for each tool category is designed at the narrowest granularity. The key for run_command is not run_command but run_command:git, run_command:cargo, run_command:docker, etc. This enables users to grant different permission levels for different commands.

4.2 The Four Levels of Permissions

CodeCoder's permission model has four levels, from permissive to strict:

Level One: None (no prompt). The tool declares Permission::None; no permission check is triggered during execution. Read-only operations like read_file, glob, grep, diff, and memory belong to this level. Their common characteristics are: they do not modify system state, do not execute external code, and do not generate network requests.

Level Two: Ask{key} (prompt). The tool declares Permission::Ask(key); a confirmation dialog is shown during execution. The user can choose:

  • Once: allow only this time
  • AlwaysThisSession: do not ask again within this session
  • AlwaysThisProject: permanently allow within this project (written to codecoder.json)

Modifying operations like run_command, write_file, and commit belong to this level.

Level Three: SessionAllowlist (session allowlist). When the user selects AlwaysThisSession, the permission is recorded in the runtime SessionAllowlist. The SessionAllowlist is an in-memory HashMap<(ToolName, PermissionKey), i64>, where the key is the tool name + permission key, and the value is an expiration timestamp (or -1 for never expires). When the session ends, this list is discarded.

Level Four: ProjectAllowlist (project allowlist). When the user selects AlwaysThisProject, the permission is persisted to the codecoder.json file. The format is as follows:

{
  "allowlist": {
    "run_command:git": "AlwaysThisProject",
    "run_command:cargo": "AlwaysThisSession",
    "write_file:tests/**": "AlwaysThisProject"
  }
}

ProjectAllowlist entries can be manually modified in a file editor -- but the system does not automatically reload them. Changes take effect after /reload or restarting the daemon.

The lookup chain is: check ProjectAllowlist first -> then SessionAllowlist -> otherwise Ask.

4.3 The Ceiling Rule

The fourth permission level (ProjectAllowlist) is not available for all tools. The key rule is: tools in the @shell environment can only reach SessionAllowlist at most.

This means that all variants of run_command (run_command:git, run_command:cargo, etc.) cannot be written to the ProjectAllowlist in codecoder.json. The user must re-authorize them for each new session.

Why? The destructive power of the shell environment is too great. run_command can execute arbitrary shell commands -- modify files, connect to the network, format disks, start services. If run_command:git were written to the ProjectAllowlist, the agent could push to a remote repository via git, and the push could not be undone.

Tools in the Wasm and Docker environments can be elevated to the ProjectAllowlist. Because Wasm sandboxing provides compile-time isolation, and Docker containers have file system isolation. Even if a tool executes malicious code, the damage is contained within the sandbox. This is the prerequisite for granting permanent trust.

The ceiling rule is a "safety valve" in the security design -- it ensures that even if the ProjectAllowlist is misconfigured, the most dangerous tools cannot be permanently authorized.


Case Layer

4.4 The Tool Trait Design

The Tool trait is the core interface of CodeCoder's tool system:

trait Tool {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn permission(&self) -> Permission;
    fn execute(&self, args: &Args, context: &Context) -> Result<ToolResult, ToolError>;
    fn parameters(&self) -> Vec<ParameterDescriptor>;
}

enum Permission {
    None,
    Ask(PermissionKey),
}

struct PermissionKey {
    tool: String,          // e.g. "run_command"
    action: String,        // e.g. "git"
    raw: String,           // full key string "run_command:git"
}

The four key methods of the Tool trait:

  • name(): returns the tool name, also used when the LLM invokes the tool
  • description(): returns the tool description, used by the LLM when selecting tools
  • permission(): returns the tool's permission level -- None or Ask{key}
  • execute(): the core logic of tool execution, receiving arguments and execution context, returning a result or error

The separation of permission() and execute() is key to the security design. permission() is called before execute(); if the permission check fails, execute() is never called. This ensures that even if there are bugs or malicious code in the tool implementation, it cannot run before the permission check passes.

4.5 Permission Key Precision

The precision of the permission key determines the granularity of user authorization. CodeCoder's design is: permission keys are at the "operation type" level, not the "specific command" level.

run_command:git allows the agent to execute all git commands -- git status, git diff, git commit, git push, git checkout. The user cannot authorize only git status while rejecting git push.

Why not finer granularity? Because when the LLM selects tools, what is presented to the LLM is the run_command tool (with a command parameter), not run_command:git or run_command:git status. Permission keys are a checking mechanism during tool execution, not a constraint mechanism during tool selection. If the LLM could see both run_command:git status and run_command:git push as separate tools, it would choose run_command:git status to run git status -- but LLM reliability is not sufficient to depend on at this granularity.

CodeCoder's permission key system includes wildcards. run_command:git:* matches all git subcommands. run_command:* matches all commands. However, the keying rules for compound commands (containing pipes, semicolons, &&) are different -- see the next section.

4.6 Compound Command Keying Rules

Compound commands are commands that contain shell metacharacters (|, ;, &&, ||, backticks). CodeCoder's keying rule for compound commands is: key the entire string; cannot be pre-authorized through prefix matching.

# Simple command
git status          -> key: run_command:git

# Compound command -- key the entire string
git status && git add .   -> key: run_command:git-status-&&-git-add

The motivation for this rule (ADR 0036) is: to prevent bypassing security restrictions through pre-authorization of simple commands. Suppose a user grants SessionAllowlist for run_command:git. If the agent executes git status && git push origin main, the key for this compound command is run_command:git-status-&&-git-push -- not in the allowlist, so re-confirmation is required.

Without this rule, an attacker could append && git push origin main after git status, bypassing the git push permission check through the pre-authorization of git status.

4.7 Complete Implementation of the Permission Lookup Chain

The permission lookup flow is illustrated below:

graph TB
    subgraph "Tool Execution Request"
        TOOL[Tool.execute invoked]
        KEY[Get Permission Key]
    end

    subgraph "Layer 1: Permission::None"
        CHECK0{permission() == None?}
        PASS0[pass through<br/>no permission check]
    end

    subgraph "Layer 2: Project Allowlist"
        CHECK1{listed in codecoder.json<br/>allowlist?}
        CEILING1{ceiling rule check}
        PASS1[allow execution<br/>AlwaysThisProject]
    end

    subgraph "Layer 3: Session Allowlist"
        CHECK2{in runtime<br/>SessionAllowlist?}
        EXPIRY{not expired?}
        PASS2[allow execution<br/>AlwaysThisSession]
    end

    subgraph "Layer 4: Ask Mode"
        ASK[prompt user]
        USER_ONCE[Once<br/>this time only]
        USER_SESSION[AlwaysThisSession<br/>write to SessionAllowlist]
        USER_PROJECT[AlwaysThisProject<br/>write to codecoder.json]
        CEILING2{ceiling rule check}
        DENY[user denied  return error]
    end

    TOOL --> KEY
    KEY --> CHECK0
    CHECK0 -->|yes| PASS0
    CHECK0 -->|no| CHECK1

    CHECK1 -->|yes| CEILING1
    CEILING1 -->|pass| PASS1
    CEILING1 -->|deny| ASK
    CHECK1 -->|no| CHECK2

    CHECK2 -->|yes| EXPIRY
    EXPIRY -->|valid| PASS2
    EXPIRY -->|expired| ASK
    CHECK2 -->|no| ASK

    ASK -->|Once| USER_ONCE
    ASK -->|ThisSession| USER_SESSION
    ASK -->|ThisProject| CEILING2
    ASK -->|No| DENY
    CEILING2 -->|pass| USER_PROJECT
    CEILING2 -->|deny| DENY

    style PASS0 fill:#e8f5e9
    style PASS1 fill:#e8f5e9
    style PASS2 fill:#e8f5e9
    style USER_ONCE fill:#fff3e0
    style USER_SESSION fill:#fff3e0
    style USER_PROJECT fill:#fff3e0
    style DENY fill:#fce4ec

The pseudocode implementation of the permission lookup chain:

fn check_permission(tool: &dyn Tool) -> Result<(), PermissionDenied> {
    let key = tool.permission_key();

    // 1. If the tool has None permission, pass directly
    if let Permission::None = tool.permission() {
        return Ok(());
    }

    // 2. Check ProjectAllowlist (persistent)
    if let Some(entry) = project_allowlist.get(&key) {
        if entry.allows_ceiling(&key) {  // Check ceiling rule
            return Ok(());
        }
    }

    // 3. Check SessionAllowlist (runtime memory)
    if let Some(entry) = session_allowlist.get(&key) {
        if entry.is_valid() {  // Check expiration
            return Ok(());
        }
    }

    // 4. None matched -> trigger Ask flow
    let response = ask_user(format!("Allow {}?", key))?;
    match response {
        Once => Ok(()),
        ThisSession => { session_allowlist.insert(key, SessionEntry::new()); Ok(()) }
        ThisProject => {
            if ceiling_rule.allows_project(&key) {  // Check ceiling rule
                project_allowlist.insert(key, ProjectEntry::new());
                Ok(())
            } else {
                Err(PermissionDenied::CeilingViolation(key))
            }
        }
    }
}

Note that there is a ceiling rule check in both step 2 and step 4. Step 2 checks whether an existing ProjectAllowlist entry is consistent with the current tool's ceiling rule (preventing manually editing codecoder.json to add entries that should not be permanently authorized). Step 4 checks whether the user's choice of ThisProject is allowed.

4.8 Sub-Agent Read-Only Tool Set

Sub-agents are created via the agent tool, and their tool set differs from the parent agent's. CodeCoder enforces a read-only tool set for sub-agents -- they can only execute 9 tools, all at the Permission::None level:

fn read_only_child_tools() -> Vec<Box<dyn Tool>> {
    vec![
        Box::new(ReadFile),
        Box::new(Glob),
        Box::new(Grep),
        Box::new(Diff),
        Box::new(WebSearch),
        Box::new(WebFetch),
        Box::new(GitHubSearch),
        Box::new(Agent),        // sub-agents can create sub-agents, but depth is locked at 1
        Box::new(Reason),
    ]
}

Sub-agents cannot write files, run commands, commit git, generate skills/capabilities, or execute capabilities. This means a sub-agent is a "pure analysis" instance -- it can read everything, search everything, but modify nothing.

The reasoning behind this design is: the sub-agent is created by the parent agent, and the parent's intent may be analytical tasks (code review, plan evaluation, risk assessment). If the sub-agent could modify system state, the parent could lose control over the sub-agent's behavior -- the sub-agent's execution results might contain unanticipated side effects. The read-only constraint eliminates this risk.

Sub-agent depth is locked at 1 -- a sub-agent can create another sub-agent (the Agent tool is in the read-only tool set), but the depth counter increments, and once it reaches 1, no further sub-agents can be created. This prevents unbounded growth of recursive sub-agents.


ADR Deep Dive

The Evolution of the Self-Authoring Safety Loop (ADR 0022)

ADR 0022 documents the design evolution of CodeCoder's self-authoring safety loop. The initial version did not have a "ceiling rule" -- all tools could be granted ProjectAllowlist. The problem was: once run_command was granted ProjectAllowlist, a Capability generated by an agent could execute arbitrary shell commands -- without passing through permission checks.

The solution was to introduce the ceiling rule: the @shell environment can only reach SessionAllowlist at most. But even after doing this, another problem emerged: the ceiling rule was only checked at runtime, not at configuration time.

Users could manually edit codecoder.json to add run_command:git to the ProjectAllowlist -- because the file is JSON format with no compile-time checks. The ceiling rule would reject this entry at runtime, but users might not understand "why did I add it but it doesn't work?" Later, a validation step was added to step 2 of the lookup chain: "check whether existing ProjectAllowlist entries are consistent with the ceiling rule" -- at least a CeilingViolation error would be returned instead of silently skipping.

The Origin of Compound Command Keying Rules (ADR 0036)

ADR 0036 was triggered by a specific bug report: a user had granted SessionAllowlist for run_command:git, and the agent executed git pull && git push --force -- the compound command was split into two run_command:git calls, because after shell parsing, git pull and git push were two independent commands.

The fix was not to prohibit semicolons, but to change the keying rules. Simple commands = key by command name; compound commands = key the entire string. The core principle of this rule is: the same permission key cannot simultaneously cover both simple and compound commands. If run_command:git is in the allowlist, it only matches git <subcommand> form simple commands, not git <subcommand> && <command> form compound commands.


Next chapter: deep dive into sub-agents and cooperative cancellation -- how agents create and destroy sub-agents, and the safety guarantees during cancellation.