harness - BashToolStrategy 安全审计策略
当前 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() 当前分两步:
- 内置规则启用时(
internalRulesEnabled默认true),按注册顺序直接检查内置规则 matcher;命中后立即返回放行或拦截结果。 - 阶段 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”的行为。