mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 20:20:54 +08:00
The approval gate for asset targets now decides. It is opt-in per task and per conversation (target_gate, default off). With the flag off nothing is extracted, resolved, read or written, and every branch of the hook behaves as before. No UI. Extraction and normalisation (1.5) - intercept.NormalizeTarget: lower case, trailing dot, userinfo, IPv6 brackets, IPv4-mapped IPv6, decimal / hex / octal / short IPv4, percent- encoded host, punycode; port explicit, else the scheme default, else 0. - intercept.ExtractTargets: WebFetch url exactly; Bash, shell_open, Monitor (command) and shell_send (text) with the command-string rules (URL with a scheme, IP, IP:port, host:port, CIDR; a bare name is not a target); every string of the input for MCP tools, custom tools and any tool the gate does not know. ExecuteExtraTool is unwrapped to the inner tool. The platform's domain tools, the file tools and web_search have no target. - The fields are decoded as the tools decode them (key case, last duplicate), and a URL authority that reads differently quoted and unquoted yields both readings. Resolver, exemptions, gate decision (1.6) - Resolver interface, system implementation (A and AAAA), 3 s per lookup, positive answers cached 10 s per host with collapsed lookups, failures and empty answers never cached, always fresh when a request is raised. - Exemptions are exact host + port. Derived from the configuration (LLM gateways, search backend in use, platform proxies): computed on every call, never resolved, never voided. Stored (settings key target_gate_exempt_hosts, seeded once with the package sources): resolved, and void when any address is private, loopback, link-local, unspecified, unique-local, multicast or a metadata address. An entry in both origins counts as derived. A stored IP literal that can never be exempt does not apply. - GET / PUT /api/target-gate/exemptions and GET .../history. A change is validated (ValidateExemptionHost on the normalised host) and appended to target_gate_exemption_log in the transaction that stores it. - Interceptor.CheckTargets: asset block rules (global and of the task) on the name and its addresses, exemptions, resolution, pin lookup. A (host, 0) pin covers every port; a (host, 443) pin covers 443 only and never a target without a declared port; two pins are never merged. At most 5 unpinned targets per call. Codes: TARGET_RULE_BLOCKED, TARGET_RESOLVE_FAILED, TARGET_TOO_MANY, TARGET_GATE_UNAVAILABLE; none opens a request. - With the upstream egress proxy set every pin is name-only at call time, whatever name_only_effective says in its row. - An approval never pins zero addresses: the pin writer refuses a request that displays none (db.ErrTargetPinEmpty, 422 on the decide endpoint, the row stays pending) and RequestTargets does not open one. - Every block, and the first use per process of each exemption and of each pin version, is a decided row (decided_by system, decision_source target). The gate in the hook (1.7) - guard.applyIntercept: a deny by a rule or by the judge blocks first, before any target request exists; then CheckTargets; an allow is logged as before and does not satisfy the gate; an ask and an unpinned target share one request, and the classic ask is raised only when it was not merged. The call is checked again after the decisions, at most three passes. - The gate applies to every tool of a gated session, enabled for rules or not, and to the target-only guard of the planner and the main agent, which still records blocks only. - Target blocks have their own model-facing text, built from the code and the reason of the result. Not in this change: rendered targets of custom tools (1.8) and redirect targets (1.9). Subprocess paths stay advisory; the TestBypass_* tests document what the gate does not see.
447 lines
16 KiB
Go
447 lines
16 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"iter"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Autumn-27/artex/agent"
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/guard"
|
|
"github.com/Autumn-27/artex/intercept"
|
|
"github.com/Autumn-27/norma/llm"
|
|
)
|
|
|
|
// Target-gate wiring: every platform-owned session starts on a context that
|
|
// carries the approval scope of its task or conversation, and the planner and
|
|
// the main agent run with the task's target-only hooks. The gate is off, so
|
|
// none of this changes what a session does.
|
|
|
|
// innerHooksProbe is a programmable inner HookRunner that records what
|
|
// steerHooks hands it.
|
|
type innerHooksProbe struct {
|
|
block bool
|
|
msg string
|
|
updated []byte
|
|
|
|
pre, post int
|
|
tool string
|
|
input string
|
|
scoped bool
|
|
}
|
|
|
|
func (p *innerHooksProbe) PreToolUse(ctx context.Context, name string, input []byte) (bool, string, []byte) {
|
|
p.pre++
|
|
p.tool, p.input = name, string(input)
|
|
_, p.scoped = intercept.TargetScopeFromContext(ctx)
|
|
return p.block, p.msg, p.updated
|
|
}
|
|
|
|
func (p *innerHooksProbe) PostToolUse(context.Context, string, []byte, []byte, bool) { p.post++ }
|
|
|
|
func (p *innerHooksProbe) Stop(context.Context, []llm.Message) (bool, []string, string) {
|
|
return false, nil, ""
|
|
}
|
|
|
|
// The worker's hooks are the task Guard wrapped in steerHooks. The wrapper must
|
|
// hand the call, on the caller's context, to the inner hooks and return their
|
|
// decision unchanged — a block decided by the Guard must not be lost on the way.
|
|
func TestSteerHooksForwardInnerBlock(t *testing.T) {
|
|
ctx := intercept.WithTargetScope(context.Background(), intercept.TargetScope{TaskID: "7"})
|
|
noSteer := func() (string, bool) { return "", false }
|
|
input := []byte(`{"command":"curl https://app.example.com/"}`)
|
|
|
|
inner := &innerHooksProbe{block: true, msg: "inner refused"}
|
|
h := steerHooks{inner: inner, drain: noSteer, nudges: &atomic.Int64{}}
|
|
block, msg, updated := h.PreToolUse(ctx, "Bash", input)
|
|
if !block || msg != "inner refused" || updated != nil {
|
|
t.Fatalf("inner block not forwarded: block=%v msg=%q updated=%q", block, msg, updated)
|
|
}
|
|
if inner.pre != 1 || inner.tool != "Bash" || inner.input != string(input) || !inner.scoped {
|
|
t.Fatalf("inner hooks saw pre=%d tool=%q input=%q scoped=%v", inner.pre, inner.tool, inner.input, inner.scoped)
|
|
}
|
|
|
|
// An inner pass (with a rewritten input) is forwarded just the same.
|
|
inner = &innerHooksProbe{updated: []byte(`{"command":"true"}`)}
|
|
h = steerHooks{inner: inner, drain: noSteer, nudges: &atomic.Int64{}}
|
|
if block, msg, updated := h.PreToolUse(ctx, "Bash", input); block || msg != "" || string(updated) != `{"command":"true"}` {
|
|
t.Fatalf("inner pass not forwarded: block=%v msg=%q updated=%q", block, msg, updated)
|
|
}
|
|
h.PostToolUse(ctx, "Bash", input, []byte(`"ok"`), false)
|
|
if inner.post != 1 {
|
|
t.Fatalf("PostToolUse reached the inner hooks %d times, want 1", inner.post)
|
|
}
|
|
|
|
// A queued steer message is delivered first; the inner hooks are not asked.
|
|
inner = &innerHooksProbe{}
|
|
h = steerHooks{inner: inner, drain: func() (string, bool) { return "change course", true }, nudges: &atomic.Int64{}}
|
|
if block, msg, _ := h.PreToolUse(ctx, "Bash", input); !block || !strings.Contains(msg, "change course") || inner.pre != 0 {
|
|
t.Fatalf("steer message: block=%v msg=%q inner.pre=%d", block, msg, inner.pre)
|
|
}
|
|
}
|
|
|
|
// scopeProbe is the target scope a session's context carried.
|
|
type scopeProbe struct {
|
|
scope intercept.TargetScope
|
|
ok bool
|
|
}
|
|
|
|
// scopeProbeProvider is a model that lists its work dir (a read-only LS call) on
|
|
// its first turn and ends the run on the next, recording the target scope of the
|
|
// context it is called with. The harness calls the model and the tool hooks on
|
|
// the same session context.
|
|
type scopeProbeProvider struct {
|
|
seen chan scopeProbe
|
|
turn atomic.Int64
|
|
}
|
|
|
|
func newScopeProbeProvider() *scopeProbeProvider {
|
|
return &scopeProbeProvider{seen: make(chan scopeProbe, 16)}
|
|
}
|
|
|
|
func (p *scopeProbeProvider) Stream(ctx context.Context, _ llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
|
|
scope, ok := intercept.TargetScopeFromContext(ctx)
|
|
select {
|
|
case p.seen <- scopeProbe{scope, ok}:
|
|
default:
|
|
}
|
|
events := []llm.StreamEvent{{Type: llm.SETextDelta, Text: "done"}, {Type: llm.SEMessageDelta, StopReason: "end_turn"}}
|
|
if p.turn.Add(1) == 1 {
|
|
events = []llm.StreamEvent{
|
|
{Type: llm.SEToolUseStart, ToolID: "call-1", ToolName: "LS"},
|
|
{Type: llm.SEToolInputJSON, Text: `{}`},
|
|
{Type: llm.SEMessageDelta, StopReason: "tool_use"},
|
|
}
|
|
}
|
|
return func(yield func(llm.StreamEvent, error) bool) {
|
|
for _, event := range events {
|
|
if !yield(event, nil) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *scopeProbeProvider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
|
|
return accumulateStreamForTest(ctx, p.Stream, req)
|
|
}
|
|
|
|
// first returns the scope of the first model call, failing if none arrives.
|
|
func (p *scopeProbeProvider) first(t *testing.T) scopeProbe {
|
|
t.Helper()
|
|
select {
|
|
case s := <-p.seen:
|
|
return s
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("the session never called the model")
|
|
return scopeProbe{}
|
|
}
|
|
}
|
|
|
|
// targetScopeFixture is a manager, engine and server on the test database.
|
|
type targetScopeFixture struct {
|
|
m *Manager
|
|
e *Engine
|
|
s *Server
|
|
}
|
|
|
|
func newTargetScopeFixture(t *testing.T) *targetScopeFixture {
|
|
t.Helper()
|
|
skipWithoutDSN(t)
|
|
m, err := NewManager(t.TempDir(), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
e := NewEngine(m)
|
|
s := &Server{m: m, engine: e, ctx: ctx, chatBusy: map[string]bool{},
|
|
chatCancel: map[string]context.CancelCauseFunc{}, taskAgents: map[string]*taskAgentBundle{}}
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
_ = m.Close()
|
|
})
|
|
return &targetScopeFixture{m: m, e: e, s: s}
|
|
}
|
|
|
|
// newTask creates a task with one open goal (so the planner loop plans).
|
|
func (f *targetScopeFixture) newTask(t *testing.T) *Task {
|
|
t.Helper()
|
|
created, err := f.m.CreateTaskWithOptions("target scope probe", "probe goal", db.TaskCreateOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task, ok := f.m.Task(created.ID)
|
|
if !ok {
|
|
t.Fatal("task not found after creation")
|
|
}
|
|
if _, err := task.Store.AddGoal(map[string]any{"summary": "probe goal"}, "seed"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return task
|
|
}
|
|
|
|
// usePlanner makes the engine resolve every task to agents driven by p.
|
|
func (f *targetScopeFixture) usePlanner(p llm.Provider) {
|
|
planner := agent.NewPlanner(p, "test-model", f.m.dir, nil, 0, 3)
|
|
worker := agent.NewWorker(p, "test-model", f.m.dir, nil, 0, 3)
|
|
f.e.SetAuthoritativeAgentResolver(func(*Task) (*agent.Planner, *agent.Worker) { return planner, worker })
|
|
}
|
|
|
|
// waitUntil polls cond until it holds, failing after 30 seconds.
|
|
func waitUntil(t *testing.T, what string, cond func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for !cond() {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("timed out waiting for %s", what)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// plannerLoopRound runs the planner loop until its first round has finished and
|
|
// returns the scope that round's session carried.
|
|
func (f *targetScopeFixture) plannerLoopRound(t *testing.T, task *Task) scopeProbe {
|
|
t.Helper()
|
|
probe := newScopeProbeProvider()
|
|
f.usePlanner(probe)
|
|
ctx, stop := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
f.e.plannerLoop(ctx, task)
|
|
}()
|
|
task.Notify()
|
|
got := probe.first(t)
|
|
waitUntil(t, "the planning round to finish", func() bool { return f.e.inflightCount(task.ID) == 0 })
|
|
stop()
|
|
<-done
|
|
return got
|
|
}
|
|
|
|
// finalPlannerRound runs the task-timeout settlement round and returns the scope
|
|
// its session carried.
|
|
func (f *targetScopeFixture) finalPlannerRound(t *testing.T, task *Task) scopeProbe {
|
|
t.Helper()
|
|
probe := newScopeProbeProvider()
|
|
f.usePlanner(probe)
|
|
f.e.runFinalPlannerRound(context.Background(), task)
|
|
return probe.first(t)
|
|
}
|
|
|
|
func (f *targetScopeFixture) mainAgentTurn(t *testing.T, task *Task) scopeProbe {
|
|
t.Helper()
|
|
probe := newScopeProbeProvider()
|
|
f.s.cfgMu.Lock()
|
|
f.s.llmOn, f.s.llmProv = true, probe
|
|
f.s.cfgMu.Unlock()
|
|
f.s.taskAgentMu.Lock()
|
|
f.s.taskAgents[task.ID] = &taskAgentBundle{main: agent.NewMainAgent(probe, "test-model", f.m.dir, nil, 0, 3)}
|
|
f.s.taskAgentMu.Unlock()
|
|
rec := httptest.NewRecorder()
|
|
f.s.chat(rec, httptest.NewRequest("POST", "/api/chat?task="+task.ID, strings.NewReader(`{"message":"status?"}`)))
|
|
if rec.Code != 202 {
|
|
t.Fatalf("chat status = %d, want 202: %s", rec.Code, rec.Body.String())
|
|
}
|
|
got := probe.first(t)
|
|
waitUntil(t, "the main-agent turn to finish", func() bool {
|
|
f.s.chatMu.Lock()
|
|
defer f.s.chatMu.Unlock()
|
|
return !f.s.chatBusy[task.ID]
|
|
})
|
|
return got
|
|
}
|
|
|
|
func (f *targetScopeFixture) workerIntent(t *testing.T, task *Task) scopeProbe {
|
|
t.Helper()
|
|
probe := newScopeProbeProvider()
|
|
if _, err := task.Store.AddIntent(map[string]any{"summary": "scope probe"}, 1, nil, "planner"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
intent, token := f.e.claimNext(task, "work#1")
|
|
if intent == nil {
|
|
t.Fatal("no intent claimed")
|
|
}
|
|
worker := agent.NewWorker(probe, "test-model", f.m.dir, nil, 0, 3)
|
|
f.e.runIntent(context.Background(), task, "work#1", worker, intent, token, "", "")
|
|
return probe.first(t)
|
|
}
|
|
|
|
func (f *targetScopeFixture) conversationTurn(t *testing.T) (scopeProbe, int64) {
|
|
t.Helper()
|
|
probe := newScopeProbeProvider()
|
|
conv, err := f.m.pg.CreateConversation("assistant", "target scope probe", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = f.m.pg.DeleteConversation(conv.ID) })
|
|
f.s.cfgMu.Lock()
|
|
f.s.chatAgent = agent.NewChatAgent(probe, "test-model", f.m.dir, nil, 10000)
|
|
f.s.cfgMu.Unlock()
|
|
busyKey := f.s.convBusyKey(conv.ID)
|
|
ctx, cancel := f.s.conversationRunContext(conv.ID, busyKey)
|
|
f.s.runConversationTurn(ctx, cancel, conv, "hello", busyKey)
|
|
return probe.first(t), conv.ID
|
|
}
|
|
|
|
// auditedTools lists the tool names in a guard's audit ring.
|
|
func auditedTools(g *guard.Guard) []string {
|
|
var tools []string
|
|
for _, e := range g.Audit() {
|
|
tools = append(tools, e.Tool)
|
|
}
|
|
return tools
|
|
}
|
|
|
|
// The five session entry points — planner round, final planner round, main-agent
|
|
// turn, worker intent, conversation turn (chat and retester share it) — each run
|
|
// on a context carrying the scope the gate will decide on, with the gate off.
|
|
// The planner and the main agent hand their tool calls to Task.TargetGuard, the
|
|
// worker to Task.Guard. The target-only guard keeps no record, so the test puts
|
|
// a recording guard in its place.
|
|
func TestEveryAgentKindCarriesTargetScope(t *testing.T) {
|
|
f := newTargetScopeFixture(t)
|
|
taskSites := []struct {
|
|
name string
|
|
run func(*testing.T, *Task) scopeProbe
|
|
wait time.Duration
|
|
targetOnly bool // the session runs on Task.TargetGuard, not Task.Guard
|
|
}{
|
|
{"planner round", f.plannerLoopRound, intercept.DefaultTargetWaitBound, true},
|
|
{"final planner round", f.finalPlannerRound, 0, true},
|
|
{"main-agent turn", f.mainAgentTurn, intercept.DefaultTargetWaitBound, true},
|
|
{"worker intent", f.workerIntent, intercept.DefaultTargetWaitBound, false},
|
|
}
|
|
for _, site := range taskSites {
|
|
t.Run(site.name, func(t *testing.T) {
|
|
task := f.newTask(t)
|
|
recorder := guard.New()
|
|
task.TargetGuard = recorder
|
|
got := site.run(t, task)
|
|
onTarget, onMain := auditedTools(recorder), auditedTools(task.Guard)
|
|
if site.targetOnly && (len(onTarget) != 1 || onTarget[0] != "LS" || len(onMain) != 0) {
|
|
t.Fatalf("tool calls seen by TargetGuard=%v Guard=%v, want one LS on TargetGuard only", onTarget, onMain)
|
|
}
|
|
if !site.targetOnly && (len(onMain) != 1 || onMain[0] != "LS" || len(onTarget) != 0) {
|
|
t.Fatalf("tool calls seen by TargetGuard=%v Guard=%v, want one LS on Guard only", onTarget, onMain)
|
|
}
|
|
if !got.ok || got.scope.TaskID != task.ID || got.scope.ConversationID != 0 {
|
|
t.Fatalf("scope = %+v (present=%v), want task %s", got.scope, got.ok, task.ID)
|
|
}
|
|
if got.scope.WaitBound != site.wait {
|
|
t.Fatalf("wait bound = %v, want %v", got.scope.WaitBound, site.wait)
|
|
}
|
|
if got.scope.Enabled == nil || got.scope.Enabled() {
|
|
t.Fatal("the gate must be off: the task did not opt in")
|
|
}
|
|
if got.scope.Interceptor != f.m.interceptor {
|
|
t.Fatal("the scope does not carry the interceptor that owns the gate")
|
|
}
|
|
})
|
|
}
|
|
t.Run("conversation turn", func(t *testing.T) {
|
|
got, convID := f.conversationTurn(t)
|
|
if !got.ok || got.scope.ConversationID != convID || got.scope.TaskID != "" {
|
|
t.Fatalf("scope = %+v (present=%v), want conversation %d", got.scope, got.ok, convID)
|
|
}
|
|
if got.scope.WaitBound != intercept.DefaultTargetWaitBound {
|
|
t.Fatalf("wait bound = %v, want %v", got.scope.WaitBound, intercept.DefaultTargetWaitBound)
|
|
}
|
|
if got.scope.Enabled == nil || got.scope.Enabled() {
|
|
t.Fatal("the gate must be off: the conversation did not opt in")
|
|
}
|
|
if got.scope.Interceptor != f.m.interceptor {
|
|
t.Fatal("the scope does not carry the interceptor that owns the gate")
|
|
}
|
|
})
|
|
}
|
|
|
|
// The final settlement round runs on a context a pause cannot cancel and is
|
|
// meant to judge, not to explore: a gated call must never wait there. A regular
|
|
// round of the same task keeps the default bound.
|
|
func TestFinalPlannerRoundHasZeroWait(t *testing.T) {
|
|
f := newTargetScopeFixture(t)
|
|
task := f.newTask(t)
|
|
regular := f.plannerLoopRound(t, task)
|
|
final := f.finalPlannerRound(t, task)
|
|
if !regular.ok || !final.ok {
|
|
t.Fatalf("scope missing: regular=%v final=%v", regular.ok, final.ok)
|
|
}
|
|
if regular.scope.WaitBound <= 0 {
|
|
t.Fatalf("regular round wait bound = %v, want a positive bound", regular.scope.WaitBound)
|
|
}
|
|
if final.scope.WaitBound != 0 {
|
|
t.Fatalf("final round wait bound = %v, want 0", final.scope.WaitBound)
|
|
}
|
|
if final.scope.TaskID != task.ID {
|
|
t.Fatalf("final round scope task = %q, want %s", final.scope.TaskID, task.ID)
|
|
}
|
|
}
|
|
|
|
// With the gate off (the task default) the target-only hooks are a no-op: a call is
|
|
// allowed and nothing is written — no intercept row, no audit-ring entry. The
|
|
// control runs the same call through the task's full Guard, where a deny rule
|
|
// blocks it and logs a row, so the rule and the row count are known to be live.
|
|
func TestTargetGateSeamAllowsWithoutDBWrite(t *testing.T) {
|
|
f := newTargetScopeFixture(t)
|
|
task := f.newTask(t)
|
|
ic := f.m.interceptor
|
|
priorTools, err := ic.GetEnabledTools()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = ic.SetEnabledTools(priorTools) })
|
|
if err := ic.SetEnabledTools([]string{"Bash"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const sentinel = "ARTEX_TARGET_SEAM_PROBE"
|
|
rule, err := f.m.pg.CreateInterceptRule("target seam probe", "tool_input", "string", sentinel, "deny", "", 1000000, true, false, 0, "deny")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = f.m.pg.DeleteInterceptRule(rule.ID)
|
|
_, _ = f.m.pg.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, task.ID)
|
|
ic.Invalidate()
|
|
})
|
|
ic.Invalidate()
|
|
rows := func() int {
|
|
t.Helper()
|
|
var n int
|
|
if err := f.m.pg.QueryRow(`SELECT count(*) FROM intercept_pending WHERE task_id=$1`, task.ID).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
ctx := intercept.WithTaskContext(context.Background(), task.ID, "planner", nil)
|
|
ctx = intercept.WithTargetScope(ctx, task.targetScope(intercept.DefaultTargetWaitBound))
|
|
input := []byte(`{"command":"echo ` + sentinel + `"}`)
|
|
|
|
if block, _, _ := task.Guard.Hooks().PreToolUse(ctx, "Bash", input); !block {
|
|
t.Fatal("control: the full guard was expected to deny the call")
|
|
}
|
|
if n := rows(); n != 1 {
|
|
t.Fatalf("control: the full guard logged %d intercept rows, want 1", n)
|
|
}
|
|
ring := len(task.Guard.Audit())
|
|
|
|
block, msg, updated := task.TargetGuard.Hooks().PreToolUse(ctx, "Bash", input)
|
|
if block || msg != "" || updated != nil {
|
|
t.Fatalf("target-only hooks: block=%v msg=%q updated=%q, want the call allowed untouched", block, msg, updated)
|
|
}
|
|
task.TargetGuard.Hooks().PostToolUse(ctx, "Bash", input, []byte(`"ok"`), false)
|
|
if n := rows(); n != 1 {
|
|
t.Fatalf("target-only hooks changed the intercept rows: %d, want 1", n)
|
|
}
|
|
if n := len(task.TargetGuard.Audit()); n != 0 {
|
|
t.Fatalf("target-only guard wrote %d audit entries, want none", n)
|
|
}
|
|
if n := len(task.Guard.Audit()); n != ring {
|
|
t.Fatalf("the task's audit ring grew from %d to %d entries", ring, n)
|
|
}
|
|
}
|