Solon v4.1.1

harness - BashToolStrategy 安全审计策略

</> markdown
2026年10月8日 上午9:11:59

当前 Bash 审计策略类是 BashToolStrategy,它实现 Agent 模块的 HITLStrategy:

public class BashToolStrategy implements HITLStrategy {
    @Override
    public String evaluate(ReActTrace trace, Map<String, Object> args) {
        // 返回 null:放行
        // 返回非空字符串:进入 HITL,字符串作为审核原因
    }
}

Harness 默认把该策略绑定到 bash。但 HITL 默认关闭;要使它对 Harness Agent 生效,还需启用 hitlEnabled,并在 Agent 工具配置中包含 hitl:

HarnessEngine engine = HarnessEngine.of("work", ".soloncode/")
        .sessionProvider(sessionProvider)
        .toolsAdd(ToolName.TOOL_BASH, ToolName.TOOL_HITL)
        .hitlEnabled(true)
        .build();

HarnessEngine.Builder.toolsAdd(...) 只有三个重载:toolsAdd(ToolName...)、toolsAdd(Collection<String>),以及已弃用的 toolsAdd(ToolPermission...)(4.0.4 起)。没有 String... 重载,所以 .toolsAdd("bash", "hitl") 无法编译(javac 会报“对于 toolsAdd(String,String),找不到合适的方法”)。需要字符串写法时用 toolsAdd(Arrays.asList("bash", "hitl"));ToolName 位于 org.noear.solon.ai.harness.agent.ToolName。

1、真实评估流程

BashToolStrategy.evaluate() 当前分两步:

  1. 内置规则启用时(internalRulesEnabled 默认 true),按注册顺序直接检查内置规则 matcher;命中后立即返回放行或拦截结果。
  2. 阶段 1 未命中时(internalRulesEnabled(false) 时则直接进入这一步),把权限上下文中的规则与内置规则合并,交给 PermissionEngine 评估,并以策略自身的 defaultDecision 作为未命中兜底。

PermissionEngine 的排序规则是:

  • priority 越大越优先;
  • 同一优先级下,DENY > ALLOW > ASK;
  • 工具名 * 匹配所有工具,其他工具名采用忽略大小写的精确匹配;
  • 参数规则可使用 glob pattern 或自定义 matcher。

需要特别注意默认值:

  • PermissionEngine.evaluate(toolName, args, context) 三参数重载未命中时默认返回 ASK;
  • BashToolStrategy 调用的是带 defaultDecision 的四参数重载,而它自身的 defaultDecision 默认为 PermissionDecision.ALLOW。

因此,当前 Bash 策略未命中任何规则时默认放行,不能把它描述为“所有非只读命令均要求人工确认”。如果需要默认询问,可显式配置:

BashToolStrategy bashStrategy = new BashToolStrategy()
        .defaultDecision(PermissionDecision.ASK);

internalRulesEnabled(false) 并不会移除内置规则。阶段 1 只是被跳过,阶段 2 仍然无条件把同一批内置规则并入(优先级 100)再评估,所以拦截结论基本不变,只是提示文案从各自的具体文案退化为通用文案(DENY 为“操作被权限策略拒绝。”,ASK 为“高危操作,需要人工介入确认。”)。个别规则还有顺序差异:阶段 1 按注册顺序(只读放行 6a 在 6b 之前),阶段 2 按 DENY > ALLOW 排序,因此 internalRulesEnabled(false) 会让 cat file.txt | 这类命令从放行变为拒绝。

2、内置 Bash 规则

BashToolStrategy 构造器注册的内置规则共 7 条(源码注释编号 1、2、3、4、5、6a、6b),优先级统一为 INTERNAL_PRIORITY = 100(高于用户规则的默认优先级 0)。并非所谓“七层安全体系”。

2.1 空命令

command 为空(或 null)时直接放行,即 evaluate() 返回 null。规则 1 的 matcher 是 Assert.isEmpty((String) args.get("command")),行为 ALLOW。

这与单独调用 BashCommandClassifier.classify("") 得到 DENY 的行为不同;BashToolStrategy 没有直接使用 classify() 的空命令分支。

2.2 字符串风险模式

命令字符串(trim 后)包含以下任一片段时拒绝:

  • 反引号 `
  • $(
  • /dev/

提示为“检测到潜在的命令注入或设备重定向风险。”。这是字符串包含判断,不是完整的 Shell 语法分析。

2.3 系统特权命令

当前正则只检查:

.*\b(sudo|su|chown|chmod|chgrp|passwd|visudo)\b.*

即 sudo、su、chown、chmod、chgrp、passwd、visudo 七个词。该规则的 prompt 为 null,命中后由 toResult() 生成包含原命令的动态提示:

检测到系统特权指令 [原命令]。

当前源码没有把 kill、pkill、systemctl、service 等进程或服务命令加入此规则。

因为是词边界匹配,passwd 也会命中 cat ../../etc/passwd 这类命令(它同时符合规则 4);而规则 3 注册在规则 4 之前,实际提示会显示为“检测到系统特权指令”。

2.4 路径回溯

命令字符串包含 ../ 或 ..\ 时拒绝,提示为“检测到路径回溯操作,禁止访问工作区外目录。”。该规则是字面字符串检查,不是对规范化文件路径执行真实边界解析。

2.5 敏感路径

命令字符串包含以下任一片段时拒绝,提示为“禁止访问系统敏感配置文件。”:

/etc/
/var/
/root/
~/.ssh/
~/.bashrc
~/.zshrc

同样,这些是字符串包含规则。

2.6 只读命令分类

BashCommandClassifier 维护只读/搜索命令集合,包括:

  • 搜索:grep、egrep、fgrep、rg、ag、ack、find、fd、locate
  • 目录:ls、dir、tree、exa
  • 文件查看:cat、bat、less、more、head、tail
  • 信息与统计:wc、file、which、whereis、whence、type、stat、du、df
  • 输出与文本处理:echo、printf、diff、comm、sort、uniq、cut、tr、awk、sed
  • 格式化:jq、yq、xmllint
  • 系统信息:pwd、env、printenv、id、whoami、hostname、uname、date、cal
  • Git 只读双词命令:git log、git show、git diff、git status、git branch、git tag、git remote、git rev-parse、git ls-files、git blame、git shortlog

这些命令仍先经过前述风险规则。例如 cat /etc/passwd 会先命中系统特权命令规则(passwd 关键词,规则 3 早于规则 5),不能因为 cat 在只读集合中就放行。

只读分类还有两个例外:

  • sed -i、sed --in-place 等原地修改形式不算只读;
  • git branch -d、git branch -D、git branch --delete 不算只读。

普通输出重定向 >、>> 会使命令不再被归类为只读;当前实现对 2> 做了特殊排除,但这仍不是完整的 Shell 重定向解析。

这两个例外只影响“能否被规则 6a 直接放行”。在默认 defaultDecision=ALLOW 下,它们既没命中 6a 也没命中其他内置规则,最终仍是放行(把默认决策改成 ASK 才会体现差别)。

2.7 管道与不完整命令

包含 | 时,分类器按管道分段;只有每一段都能被识别为只读命令,整个命令才按只读放行。这不是一份独立的“管道安全工具白名单”。

不完整命令规则(规则 6b,注册在只读放行 6a 之后)使用正则:

(\|\s*|&&\s*|\|\|\s*|;\s*)$

即命令尾部是以下符号时按“不完整命令”拒绝,提示为“检测到不完整的命令或空命令。”(提示中的“空命令”实际不会出现:空命令已由规则 1 放行):

|  &&  ||  ;

由于规则 6a 排在 6b 之前,且管道分段使用 split("\\|") 会丢弃尾部空段,所以当命令头部本身是只读命令时,尾部符号不会触发 6b:cat file.txt |、ls &&、ls ; 都会被放行。只有非只读命令(如 npm run build |)才会命中 6b。该检查只针对尾部,不代表完整拦截 ;、& 或其他组合命令。

3、当前没有声明的内置硬拦截

不要把以下能力归因于当前 BashToolStrategy:

  • 不存在包管理器或环境变更命令的统一硬拦截;
  • 不存在 curl、wget、ssh 等网络命令的统一硬拦截;
  • 不存在 rm -rf、mv 的专门内置硬拦截;
  • 不存在完整的多命令组合解析与统一拒绝;
  • BashCommandClassifier 虽定义了 Git 写命令集合和 isGitWriteCommand(),但当前 BashToolStrategy 没有调用它。

这些命令如果没有命中其他内置规则或额外权限规则,会落到 Bash 策略的默认决策;当前默认是 ALLOW。

4、通过 PermissionRule 添加约束

额外规则应通过 PermissionRule / PermissionContext 配置,而不是假定内置策略已经覆盖。

4.1 glob 规则

HarnessEngine engine = HarnessEngine.of("work", ".soloncode/")
        .sessionProvider(sessionProvider)
        .toolsAdd(ToolName.TOOL_BASH, ToolName.TOOL_HITL)
        .hitlEnabled(true)
        .build();

engine.addPermissionRule(
        PermissionRule.deny("bash", "rm -rf *").priority(10));
engine.addPermissionRule(
        PermissionRule.ask("bash", "git push *").priority(10));
engine.addPermissionRule(
        PermissionRule.allow("bash", "git log *").priority(10));

pattern 会针对参数中第一个可用字符串字段匹配,字段顺序为:

command, file_path, path, content, url, link

若这些字段都不是字符串,则退化为对 args.toString() 做匹配。

* 和 ? 按 glob 转换(globToRegex),并且使用 Pattern.matches() 做整串匹配、忽略大小写。例如要匹配任意位置的关键词,需要在 pattern 中显式加入 *:git push * 无法匹配 cd /tmp && git push origin main。

PermissionRule.priority(int) 返回新规则,调用结果必须使用。内置规则优先级为 100,用户规则要排在它们之前,priority 需要大于 100;否则只会在内置规则未命中时才有机会生效(阶段 1 的内置直接命中更早于引擎评估)。对于自定义 matcher,应直接使用带 priority 参数的 PermissionRule.of(...) 创建;不要再调用 priority(...),因为当前实现只复制 pattern,会丢失 matcher 与 prompt。

4.2 自定义 matcher

PermissionRule denyNetwork = PermissionRule.of(
        "bash",
        PermissionBehavior.DENY,
        (toolName, args) -> {
            Object value = args == null ? null : args.get("command");
            if (!(value instanceof String)) {
                return false;
            }

            String command = ((String) value).trim();
            return command.startsWith("curl ")
                    || command.startsWith("wget ")
                    || command.startsWith("ssh ");
        },
        "网络命令被当前业务策略拒绝。",
        200);

engine.addPermissionRule(denyNetwork);

内置 matcher 在第一阶段直接命中时会立即返回。若业务要求完全不同的评估顺序或覆盖语义,更适合注册自定义 HITLStrategy,而不是依赖普通规则覆盖内置直接匹配。

5、自定义 HITLStrategy

HITLStrategy auditStrategy = new HITLStrategy() {
    @Override
    public String evaluate(ReActTrace trace, Map<String, Object> args) {
        Object value = args == null ? null : args.get("command");
        String command = value instanceof String ? (String) value : "";

        if (command.contains("rm -rf")) {
            return "检测到递归删除操作:" + command;
        }
        if (command.startsWith("curl ") || command.startsWith("wget ")) {
            return "网络访问需要人工确认:" + command;
        }
        return null;
    }
};

HITLInterceptor hitl = new HITLInterceptor()
        .onTool("bash", auditStrategy);

HarnessEngine engine = HarnessEngine.of("work", ".soloncode/")
        .sessionProvider(sessionProvider)
        .toolsAdd(ToolName.TOOL_BASH, ToolName.TOOL_HITL)
        .hitlInterceptor(hitl)
        .hitlEnabled(true)
        .build();

替换 HITLInterceptor 会同时替换 Harness 默认的 Bash 策略和默认 onApproved 记忆回调;需要的行为应自行重新注册。

Harness 的默认接线(initHitlDefault(),仅在未设置 hitlInterceptor 时执行)是:new BashToolStrategy().permissionContextSupplier(options::getPermissionContext) 注册到 bash;同时挂上默认 onApproved 回调——人工选择“总是允许”时,把本次的 command(write/edit 工具则取 file_path)作为 glob pattern 写入全局权限上下文,后续相同操作自动放行;没有可取字段时退化为工具级放行规则。

6、运行时开关

engine.setHitlEnabled(true);
engine.setHitlEnabled(false);

该方法会同步更新 Harness 配置和拦截器的 enabled 状态。它只控制 HITL 是否启用,不会改变 BashToolStrategy 的规则集合或“未命中默认 ALLOW”的行为。