DEV Community

Solon Framework
Solon Framework

Posted on

Anatomy of an Agent Harness: Permission Rules, HITL Strategies, and Markdown Subagents in Solon AI

Every agent framework eventually hits the same wall: the model is happy to rm -rf if that looks like progress. What separates a demo from something you can run near production code is not the reasoning loop — it is everything wrapped around it. Who is allowed to call which tool? With what arguments? And who gets asked when the answer is "it depends"?

That is what solon-ai-harness is about. It is the orchestration container at the center of Solon AI's agent stack, and this is a tour of how it answers those questions — mostly in code you can read in an afternoon, which is the point.

What a "harness" actually does

The word harness is borrowed from the gear that lets you safely ride something more powerful than you. HarnessEngine earns the name honestly: at construction time it wires together twenty-plus components from eight dependency packages — the terminal tools, skills, web search, LSP, memory, MCP and OpenAPI gateways — and at build time AgentFactory.create() hands each agent only the slice it is entitled to, based on its definition's tool list.

A harness is a capability integrator with a bouncer at every door.

The elegant part: an agent's definition is a Markdown file.

---
name: "bash"
description: "Bash 命令执行专家,专门执行 git 操作、命令行任务和终端操作(没有文件写权限)"
tools: ["list", "read", "bash"]
---

你是一个命令行执行专家,专门负责执行各种 shell 命令和操作...
Enter fullscreen mode Exit fullscreen mode

YAML frontmatter, a system prompt, done. The tools list is the permission list — the factory resolves each name to a component and registers nothing else. The module ships with five built-in agents defined this way: general (all public tools), bash, explore, plan, and one hidden git-summary. Your own agents are just more Markdown, loaded from a mount:

HarnessEngine engine = HarnessEngine.of("/data/work/", ".tmp")
        .toolsAdd(ToolName.TOOL_ALL_FULL)
        .mountAdd(Mount.builder()
                .alias("@global-agents")
                .type(MountType.AGENTS)
                .source(FileMountSource.of(Paths.get(
                        System.getProperty("user.home"), ".soloncode", "agents")))
                .build())
        .modelAdd(new ChatConfig().then(s -> {
            s.setApiUrl("https://api.deepseek.com");
            s.setModel("deepseek-v4-flash");
        }))
        .build();
Enter fullscreen mode Exit fullscreen mode

Note the mount alias: agents can be swapped per environment (team server vs. laptop) without touching engine code. Definitions are data, not code.

The permission model: three verdicts, one tie-break rule

Under the hood, every interception question ends in one of three verdicts: ALLOW, DENY, or ASK (defer to a human). The PermissionEngine evaluates rules with a semantics you can state in one sentence:

Higher priority wins; among equal priorities, DENY beats ALLOW beats ASK; if nothing matches, fall back to the caller's default decision.

new PermissionEngine().evaluate(toolName, args, context);
// no rule matched → defaults to ASK
Enter fullscreen mode Exit fullscreen mode

That default-to-ASK is a philosophical choice worth noticing: when the harness doesn't know, it asks a human rather than guessing.

Rules themselves are tiny objects — either a glob pattern or a custom BiPredicate on the raw arguments, never both:

PermissionRule.withPattern("bash", PermissionBehavior.ALLOW, "git status *", 10);
// or, with a matcher:
PermissionRule.of("bash", PermissionBehavior.DENY,
        (tool, args) -> ((String) args.get("command")).contains("rm -rf"),
        "Destructive command blocked.", 20);
Enter fullscreen mode Exit fullscreen mode

(The factory split is deliberate: of(...) for matcher rules, withPattern(...) for glob rules — the two can't be mixed on one rule.)

And rules compose across scopes: the engine holds a global PermissionContext, and each agent can carry its own delta — at evaluation time the agent-level rules merge on top of the global ones. Team-wide "never touch /etc", plus one agent that may force-push in the release repo.

Three strategies, one interceptor

PermissionEngine is the generic brain, but the harness also ships three domain-specific strategies that plug into the HITL (human-in-the-loop) interceptor — one per dangerous tool family:

1. BashToolStrategy — the deep one

Bash is where agents do real damage, so this strategy layers its defense:

Built-in rules run first, at priority 100 (user rules can't out-prioritize them without trying). The built-in set, straight from the source:

# Rule Verdict
1 Empty command ALLOW (nothing to check)
2 Contains backtick, $(, or /dev/ DENY — injection / device redirection
3 sudo, su, chmod, chown, passwd… DENY — privilege escalation
4 Contains ../ DENY — path traversal
5 Touches /etc/, /var/, /root/, ~/.ssh/, shell rc files DENY — sensitive system files
6a Whitelisted read-only command ALLOW
6b Trailing `\ , &&, \

Then comes the part I find genuinely clever: the read-only whitelist. Fifty-plus commands — {% raw %}grep, ls, cat, head, wc, diff, jq, sed… plus two-word git read commands like git log and git diff — pass automatically. A pipeline passes only if every segment is read-only: cat app.log \| grep ERROR is fine, cat app.log \| sh is not.

Everything else defaults to ASK. Which means the interruption model is: boring commands run, weird commands ask, dangerous commands never run. As a developer, you only get pinged for the interesting cases.

One design note: BashToolStrategy's no-match default is ALLOW, not ASK — the built-in whitelist already classifies the known universe; unknown-but-not-flagged commands are trusted. You can flip either layer: internalRulesEnabled(false) drops the built-in rules entirely, or set a stricter defaultDecision.

2. WriteToolStrategy

The same shape, aimed at file writes: deny ../ traversal, deny writes to system paths and shell rc files. Path-based instead of content-based — shorter rule list, same two-rule skeleton.

3. WebToolStrategy

A hostname denylist: registered risky domains are rejected outright, and the rejection message dynamically includes the offending hostname so the model (and the human watching) sees why.

Three strategies, three shapes of risk: command text, file path, network destination. Adding your own for a new tool family is the same two steps: a couple of PermissionRules, and the PermissionContext supplier wiring.

Subagents, and the tools that make more agents

Two more pieces complete the picture:

  • TaskTalent — the subagent dispatcher: delegate work to a named agent (serial) or fan out several in parallel. Agents run with their own tool entitlement; the bash agent can't write files because its Markdown never asked for the write tool.
  • GenerateTalent — an agent that creates other agents at runtime: describe a one-off role, get a definition, instantiate, run, discard. Combined with AgentFactory.createSubagent(definition) you can spin up narrowly-scoped workers on demand:
AgentDefinition def = new AgentDefinition();
def.setSystemPrompt("You audit generated SQL for full-table scans.");
def.getMetadata().addTools(ToolName.TOOL_READ);  // read-only, by construction

ReActAgent auditor = engine.createSubagent(def).build();
Enter fullscreen mode Exit fullscreen mode

An auditor that literally cannot write anything. Not "was told not to" — cannot.

Worth stealing even if you never use Solon

Strip away the Java and the design still transfers:

  1. Make the permission list the definition format. Frontmatter in a Markdown file is reviewable, diffable, and portable — permissions as data beats permissions as wiring.
  2. Read-only whitelists before denylists. A whitelist turns "ask for everything" from harassment into a signal.
  3. Three verdicts, not two. ASK is the pressure valve that keeps DENY rare and meaningful.
  4. Deterministic tie-breaks. DENY > ALLOW > ASK at equal priority means rule conflicts resolve the same way every time — no "whichever rule registered first" roulette.
  5. Built-in rules outrank user rules. The floor stays under your feet even when users get creative.

Solon AI runs Java 8 through 26, Apache 2.0, and the harness module is on Maven Central as org.noear:solon-ai-harness (in the 4.1 line; the module itself has been published since the 3.10 series). Repository: github.com/opensolon/solon-ai.


Every technical claim in this piece was checked against the solon-ai-harness source (v4.1 line) — rule lists, priorities, defaults, and agent definitions all read directly from the repository. If you find a discrepancy, the comments are the fastest way to reach me.

Top comments (0)