diff --git a/agent/agentic_guards_test.go b/agent/agentic_guards_test.go new file mode 100644 index 0000000..493c2b9 --- /dev/null +++ b/agent/agentic_guards_test.go @@ -0,0 +1,177 @@ +package agent + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "testing" + + "github.com/Autumn-27/artex/db" + actool "github.com/Autumn-27/norma/tool" +) + +// guardTaskStore creates a throwaway task (PG) and returns its exploration store. +// It has no goal node until the test adds one. +func guardTaskStore(t *testing.T) *db.ExplorationStore { + t.Helper() + d := testDB(t) + t.Cleanup(func() { _ = d.Close() }) + task, err := d.CreateTaskWithOptions("agentic guards", "exercise the goal and hint guards", db.TaskCreateOptions{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(task.ID) }) + return d.Exploration(task.ExplorationID) +} + +func callGuardTool(t *testing.T, tool actool.CoreTool, input string) actool.Result { + t.Helper() + res, err := tool.Call(context.Background(), json.RawMessage(input), nil) + if err != nil { + t.Fatalf("%s: transport error: %v", tool.Name(), err) + } + return res +} + +func addGuardNode(t *testing.T, s *db.ExplorationStore, kind string, payload map[string]any, state string) int64 { + t.Helper() + id, err := s.AddNode(kind, payload, 0, state, "worker-1", nil) + if err != nil { + t.Fatal(err) + } + return id +} + +func nodeState(t *testing.T, s *db.ExplorationStore, id int64) string { + t.Helper() + n, err := s.GetNode(id) + if err != nil || n == nil { + t.Fatalf("node %d: %v", id, err) + } + return n.State +} + +// goal_met refuses while a goal node is open, and ends the task once every goal +// is met (#124, F5). +func TestGoalMetRefusesWhileGoalsOpen(t *testing.T) { + s := guardTaskStore(t) + open := addGuardNode(t, s, db.KindGoal, map[string]any{"text": "read the admin panel"}, "open") + addGuardNode(t, s, db.KindGoal, map[string]any{"text": "list the users"}, "met") + + tsx := NewToolSet(s, "planner") + res := callGuardTool(t, tsx.goalMet(), `{"reason":"the data says we are done"}`) + if !res.IsError || !strings.Contains(res.Flatten(), "1 of 2 goals") || !strings.Contains(res.Flatten(), "prove_goal") { + t.Fatalf("open goal: want a refusal naming the open goals and prove_goal, got IsError=%v %q", res.IsError, res.Flatten()) + } + if tsx.GoalMet { + t.Fatal("goal_met set GoalMet while a goal is open") + } + + if err := s.SetNodeState(open, "met"); err != nil { + t.Fatal(err) + } + res = callGuardTool(t, tsx.goalMet(), `{"reason":"both goals proven"}`) + if res.IsError || !tsx.GoalMet || tsx.Reason != "both goals proven" { + t.Fatalf("all goals met: IsError=%v GoalMet=%v reason=%q %q", res.IsError, tsx.GoalMet, tsx.Reason, res.Flatten()) + } +} + +// A task without goal nodes keeps the old goal_met behavior. +func TestGoalMetWithoutGoalNodes(t *testing.T) { + tsx := NewToolSet(guardTaskStore(t), "planner") + res := callGuardTool(t, tsx.goalMet(), `{"reason":"task goal reached"}`) + if res.IsError || !tsx.GoalMet { + t.Fatalf("no goal node: IsError=%v GoalMet=%v %q", res.IsError, tsx.GoalMet, res.Flatten()) + } +} + +// prove_goal accepts a fact only when its confidence is observed; a finding is +// still accepted (#124, F5). +func TestProveGoalRequiresObservedFact(t *testing.T) { + s := guardTaskStore(t) + goalA := addGuardNode(t, s, db.KindGoal, map[string]any{"text": "get admin"}, "open") + goalB := addGuardNode(t, s, db.KindGoal, map[string]any{"text": "dump the users"}, "open") + inferred := addGuardNode(t, s, db.KindFact, map[string]any{"summary": "admin is probably reachable", "confidence": "inferred"}, "confirmed") + unlabeled := addGuardNode(t, s, db.KindFact, map[string]any{"summary": "admin reachable"}, "confirmed") + observed := addGuardNode(t, s, db.KindFact, map[string]any{"summary": "logged in as admin", "confidence": " Observed "}, "confirmed") + finding := addGuardNode(t, s, db.KindFinding, map[string]any{"summary": "user table dumped"}, "confirmed") + + tsx := NewToolSet(s, "planner") + prove := func(goal, ev int64) actool.Result { + return callGuardTool(t, tsx.proveGoal(), `{"goal_id":`+strconv.FormatInt(goal, 10)+`,"evidence_id":`+strconv.FormatInt(ev, 10)+`,"reason":"x"}`) + } + + for name, ev := range map[string]int64{"inferred": inferred, "unlabeled": unlabeled} { + res := prove(goalA, ev) + if !res.IsError || !strings.Contains(res.Flatten(), "observed") || !strings.Contains(res.Flatten(), "stays open") { + t.Fatalf("%s fact: want a refusal, got IsError=%v %q", name, res.IsError, res.Flatten()) + } + if st := nodeState(t, s, goalA); st != "open" { + t.Fatalf("%s fact: goal state %q, want open", name, st) + } + } + if tsx.GoalMet { + t.Fatal("a refused proof set GoalMet") + } + + if res := prove(goalA, observed); res.IsError || nodeState(t, s, goalA) != "met" { + t.Fatalf("observed fact: IsError=%v state=%q %q", res.IsError, nodeState(t, s, goalA), res.Flatten()) + } + if tsx.GoalMet { + t.Fatal("GoalMet set while goal B is open") + } + if res := prove(goalB, finding); res.IsError || nodeState(t, s, goalB) != "met" || !tsx.GoalMet { + t.Fatalf("finding: IsError=%v state=%q GoalMet=%v %q", res.IsError, nodeState(t, s, goalB), tsx.GoalMet, res.Flatten()) + } +} + +// add_hint stores the calling ToolSet as the hint's origin and passes it to the +// trigger (#124, F6): an orchestrator hint is not labelled human, and the main +// agent (worker "human") still is. +func TestAddHintRecordsOrigin(t *testing.T) { + s := guardTaskStore(t) + for _, origin := range []string{"orchestrator", "human"} { + tsx := NewToolSet(s, origin) + var gotOrigin string + var gotTexts []string + tsx.SetNotifyHint(func(o string, texts []string) { gotOrigin, gotTexts = o, texts }) + res := callGuardTool(t, tsx.AddHintTool(), `{"text":"focus on the export endpoint"}`) + out := res.Flatten() + if res.IsError || !strings.HasPrefix(out, "hint added: ") { + t.Fatalf("%s: add_hint failed: %q", origin, out) + } + id, err := strconv.ParseInt(strings.TrimPrefix(out, "hint added: "), 10, 64) + if err != nil { + t.Fatalf("%s: hint id in %q: %v", origin, out, err) + } + n, err := s.GetNode(id) + if err != nil || n == nil || n.Kind != db.KindHint || n.Origin != origin { + t.Fatalf("%s: hint node %+v err=%v, want origin %q", origin, n, err, origin) + } + if gotOrigin != origin || len(gotTexts) != 1 || gotTexts[0] != "focus on the export endpoint" { + t.Fatalf("%s: trigger got origin=%q texts=%v", origin, gotOrigin, gotTexts) + } + } +} + +// renderTriggers names the writer of a hint from its origin; only the main agent +// is "the human" (#124, F6). The hint case never reads the store. +func TestRenderTriggersHintOrigin(t *testing.T) { + ts := &db.ExplorationStore{} + for origin, want := range map[string]string{ + "human": "- The human (via the main agent) added a strategic hint: h1", + "orchestrator": "- A cross-task orchestration added a strategic hint: h1", + "worker-3": "- An agent (worker-3) added a strategic hint: h1", + "": "- A source that was not recorded added a strategic hint: h1", + } { + got := renderTriggers(ts, []TriggerEvent{{Kind: "hint", Hints: []string{"h1"}, HintOrigin: origin}}) + if !strings.Contains(got, want) || strings.Contains(got, "The human (main agent)") { + t.Errorf("origin %q: got %q, want %q", origin, got, want) + } + } + got := renderTriggers(ts, []TriggerEvent{{Kind: "hint", Hints: []string{"h1", "h2"}, HintOrigin: "orchestrator"}}) + if !strings.Contains(got, "- A cross-task orchestration added 2 strategic hints: h1; h2") { + t.Errorf("batch: got %q", got) + } +} diff --git a/agent/mainagent.go b/agent/mainagent.go index cbb5710..d6e7443 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -111,7 +111,7 @@ func mainAgentSystem(goal, dataDir, workDir string) string { // text / result) so the main-agent session shows its work — exactly like the // worker/planner sessions — not just the final answer. hooks (the task's // target-only Guard) sees every tool call; may be nil. -func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db.AssetStore, ts *db.ExplorationStore, goal, message string, hooks harness.HookRunner, emit func(db.Activity), notify, resume func(), notifyGoal, notifyHint func([]string)) (string, error) { +func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db.AssetStore, ts *db.ExplorationStore, goal, message string, hooks harness.HookRunner, emit func(db.Activity), notify, resume func(), notifyGoal func([]string), notifyHint func(string, []string)) (string, error) { tsx := NewToolSet(ts, "human") tsx.SetFindingRecorder(m.findingRecorder) if as != nil { @@ -122,7 +122,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db. tsx.SetNotify(notify) // generic wake-up (write ops without a dedicated callback go through it, debounced) tsx.SetResumeTask(resume) // set_goals adds a goal → pull a completed/paused task back to running tsx.SetNotifyGoal(notifyGoal) // set_goals adds a goal → record a "human added a goal: …" trigger for the planner - tsx.SetNotifyHint(notifyHint) // add_hint adds hints → record a "human added N strategic hints: …" trigger for the planner + tsx.SetNotifyHint(notifyHint) // add_hint adds hints → record a "human added N strategic hints: …" trigger for the planner (origin "human") tsx.steerWork = m.steerWork // enable steer_work tool (nil = unavailable) // domain tools + basic default toolset (Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash) // when the asset coverage feature is off, drop add_task_scope/list_untested_assets (not in the prompt). diff --git a/agent/planner.go b/agent/planner.go index d6fe234..dad57f3 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -195,6 +195,9 @@ func plannerGoalAnchor(goal, constraints string) string { // "cancelled" — the human deleted intent IntentID (Detail = the deletion reason). // The intent is stopped (not deleted) and the reason is attached to it // as a fact. +// "hint" — one add_hint call added one OR MORE hints (Hints = the hint texts; +// HintOrigin = who wrote them: "human" via the main agent, +// "orchestrator" for cross-task orchestration, otherwise an agent name). type TriggerEvent struct { Kind string IntentID int64 @@ -204,6 +207,25 @@ type TriggerEvent struct { OldGoal string // Kind=="goal_edited" only: the goal text before the edit NewGoal string // Kind=="goal_edited" only: the goal text after the edit Hints []string // Kind=="hint" only: the hint texts added by this add_hint call (one or more) + // HintOrigin is the writer of the hints (Kind=="hint" only): the origin stored + // on the hint nodes. Empty when unknown. + HintOrigin string +} + +// hintAuthor names the writer of a hint for the planner, from its stored origin. +// Only the main-agent chat writes as "human"; the other writers are named as +// what they are, so an agent-written hint is not read as an operator instruction. +func hintAuthor(origin string) string { + switch origin { + case "human": + return "The human (via the main agent)" + case "orchestrator": + return "A cross-task orchestration" + case "": + return "A source that was not recorded" + default: + return "An agent (" + origin + ")" + } } // renderTriggers spells out the change(s) that fired this round: for a finished @@ -226,9 +248,9 @@ func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string { } case "hint": if len(ev.Hints) == 1 { - b.WriteString(fmt.Sprintf("\n- The human (main agent) added a strategic hint: %s — already attached to the exploration graph; please adjust/add exploration directions accordingly (if no corresponding intent exists yet).", ev.Hints[0])) + b.WriteString(fmt.Sprintf("\n- %s added a strategic hint: %s — already attached to the exploration graph; please adjust/add exploration directions accordingly (if no corresponding intent exists yet).", hintAuthor(ev.HintOrigin), ev.Hints[0])) } else { - b.WriteString(fmt.Sprintf("\n- The human (main agent) added %d strategic hints: %s — all are attached to the exploration graph; please adjust/add exploration directions accordingly, one by one.", len(ev.Hints), strings.Join(ev.Hints, "; "))) + b.WriteString(fmt.Sprintf("\n- %s added %d strategic hints: %s — all are attached to the exploration graph; please adjust/add exploration directions accordingly, one by one.", hintAuthor(ev.HintOrigin), len(ev.Hints), strings.Join(ev.Hints, "; "))) } case "goal_deleted": b.WriteString(fmt.Sprintf("\n- The human deleted this goal: %s — the goal has been removed; re-judge the remaining goals/directions accordingly (no need to dispatch intents for it anymore).", ev.Detail)) diff --git a/agent/tools.go b/agent/tools.go index 62c25b4..aa8e74e 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -187,13 +187,14 @@ type ToolSet struct { // agent; nil for the goals decomposer (round-0 has no running planner to inform) // and workers → those fall back to the bare notify. notifyGoal func(texts []string) - // notifyHint, if set, wakes the planner AND records ONE "human added N strategic + // notifyHint, if set, wakes the planner AND records ONE " added N strategic // hints: …" trigger for a whole add_hint call (batch-aware — one call, one // trigger) so the next round is told the round was fired by a new hint and spells // the hint out, instead of the planner having to spot it folded into the graph - // overview. Wired for the main agent + cross-task orchestration; nil elsewhere → - // falls back to the bare notify. - notifyHint func(texts []string) + // overview. origin is the ToolSet's worker (human, orchestrator, or an agent name). + // Wired for the main agent + cross-task orchestration; nil elsewhere → falls back + // to the bare notify. + notifyHint func(origin string, texts []string) } // SetNotifyGoal wires the goal-add trigger callback (see ToolSet.notifyGoal). Set only @@ -203,7 +204,7 @@ func (t *ToolSet) SetNotifyGoal(fn func([]string)) { t.notifyGoal = fn } // SetNotifyHint wires the hint-add trigger callback (see ToolSet.notifyHint). Set by // the main-agent chat and cross-task orchestration, so a runtime-added hint fires a // planner round announced by name instead of a bare wake. -func (t *ToolSet) SetNotifyHint(fn func([]string)) { t.notifyHint = fn } +func (t *ToolSet) SetNotifyHint(fn func(string, []string)) { t.notifyHint = fn } // SetResumeTask wires the task-revive callback (see ToolSet.resumeTask). Set only by // the main-agent chat, so runtime-added goals can pull a finished task back to running. @@ -1288,7 +1289,7 @@ func (t *ToolSet) listGoals() actool.CoreTool { } func (t *ToolSet) proveGoal() actool.CoreTool { - return t.writeExpTool("prove_goal", "Call when you judge that a finding/fact proves a goal met: links the evidence node to the goal node and marks the goal met.", + return t.writeExpTool("prove_goal", "Call when you judge that a finding/fact proves a goal met: links the evidence node to the goal node and marks the goal met. A fact counts as evidence only when its confidence is observed; an inferred fact is refused and the goal stays open.", obj(map[string]any{ "goal_id": idp("goal node id"), "evidence_id": idp("id of the finding/fact node that proves it"), @@ -1313,6 +1314,20 @@ func (t *ToolSet) proveGoal() actool.CoreTool { if err != nil || evidenceNode == nil || (evidenceNode.Kind != db.KindFact && evidenceNode.Kind != db.KindFinding) { return actool.Errorf("evidence_id must be a fact/finding node of this task or a directly related task"), nil } + // A fact proves a goal only when it was seen directly (#124, F5): an inferred + // conclusion, or one without a confidence, is refused and the goal stays open. + // The confidence is declared by the agent that recorded the fact. + if evidenceNode.Kind == db.KindFact { + var fp map[string]any + _ = json.Unmarshal(evidenceNode.Payload, &fp) + confidence, _ := fp["confidence"].(string) + if strings.ToLower(strings.TrimSpace(confidence)) != "observed" { + if confidence == "" { + confidence = "none" + } + return actool.Errorf(fmt.Sprintf("prove_goal refused: fact %d has confidence %q, and a fact proves a goal only when its confidence is \"observed\" (seen directly in tool output). Goal %d stays open: prove it with a finding or with an observed fact.", ev, confidence, goal)), nil + } + } _ = t.ts.Link(ev, db.RelProves, goal) _ = t.ts.SetNodeState(goal, "met") // Each time a goal is marked met, check whether [all goals] of this task are @@ -1337,11 +1352,26 @@ func (t *ToolSet) proveGoal() actool.CoreTool { } func (t *ToolSet) goalMet() actool.CoreTool { - return writeTool("goal_met", "[End the entire task immediately] — call only when you are sure [all of the task's goals are truly met and it is complete overall] (note: the task as a [whole]; having met just one goal / one flag / one finding [does not count] — in that case mark that goal with prove_goal). ⚠️ It is NOT for \"ending this planning round\": if there is no new intent to dispatch this round, or you're waiting on worker output, [just end the round — do NOT call this tool] (0 intents is a perfectly normal outcome). Prefer prove_goal to prove goals one by one; goal_met is only a means to skip per-goal proofs and complete the task globally.", + return t.writeExpTool("goal_met", "[End the entire task immediately] — call only when you are sure [all of the task's goals are truly met and it is complete overall] (note: the task as a [whole]; having met just one goal / one flag / one finding [does not count] — in that case mark that goal with prove_goal). ⚠️ It is NOT for \"ending this planning round\": if there is no new intent to dispatch this round, or you're waiting on worker output, [just end the round — do NOT call this tool] (0 intents is a perfectly normal outcome). Prefer prove_goal to prove goals one by one; goal_met is only a means to skip per-goal proofs and complete the task globally.", obj(map[string]any{"reason": str("grounds for completion (must be evidence that the goals are truly met, not a round-ending reason like 'no new directions this round')")}, "reason"), func(_ context.Context, in json.RawMessage) (actool.Result, error) { var a struct{ Reason string } _ = json.Unmarshal(in, &a) + // goal_met ends the task only when no goal node is open (#124, F5). A task + // without goal nodes keeps the old behavior. + goals, err := t.ts.ListByKind(db.KindGoal, 1000) + if err != nil { + return actool.Errorf("goal_met refused: the goals of this task could not be read (" + err.Error() + "); the task stays open"), nil + } + open := 0 + for _, g := range goals { + if g.State != "met" { + open++ + } + } + if open > 0 { + return actool.Errorf(fmt.Sprintf("goal_met refused: %d of %d goals of this task are still open; the task stays open. Prove each one with prove_goal(goal_id, evidence_id, reason): the task completes automatically when its last goal is marked met.", open, len(goals))), nil + } t.GoalMet = true t.Reason = a.Reason return actool.Text("acknowledged: goal marked met"), nil @@ -1547,8 +1577,9 @@ type hintItem struct { TrafficRefs []db.TrafficRef `json:"traffic_refs"` } -// addOneHint attaches a hint node (active/human) to the exploration graph, optionally -// anchored to assets; returns the id. +// addOneHint attaches a hint node (active) to the exploration graph, optionally +// anchored to assets; returns the id. Its origin is t.worker, the agent that wrote +// it: "human" for the main-agent chat, "orchestrator" for cross-task orchestration. func (t *ToolSet) addOneHint(it hintItem) (int64, error) { if len(it.TrafficRefs) > 0 && !findingTrafficBindingEnabled() { return 0, fmt.Errorf("automatic agent traffic binding is off; the hint carrying traffic_refs was not saved — enable it in system settings, or hand over text only") @@ -1573,7 +1604,7 @@ func (t *ToolSet) addOneHint(it hintItem) (int64, error) { // Waking the planner is not done here per item — addHint fires it once after the // whole batch is written (carrying the hint texts), so one add_hint with multiple // hints doesn't spam the planner's trigger lines one by one. - return t.ts.AddNode(db.KindHint, payload, 0, "active", "human", anchors) + return t.ts.AddNode(db.KindHint, payload, 0, "active", t.worker, anchors) } type goalItem struct { @@ -1763,7 +1794,7 @@ func (t *ToolSet) setConstraints() actool.CoreTool { } func (t *ToolSet) addHint() actool.CoreTool { - return t.writeExpTool("add_hint", "Attach a strategic hint from the human/main agent to the exploration graph; the planner reads it the next time it generates intents.\n"+ + return t.writeExpTool("add_hint", "Attach a strategic hint to the exploration graph; the planner reads it the next time it generates intents. The hint is stored with the calling agent as its origin (human for the main-agent chat, orchestrator for cross-task orchestration, otherwise the agent's name).\n"+ "★Batch preferred: put multiple hints in the hints array in a single submission (saves round trips vs. one call per item). Returns an ids array, same length and order as hints (failed items get id=0, details in errors). For a single hint, omit hints and pass the top-level text.", obj(map[string]any{ "hints": map[string]any{"type": "array", "description": "[Prefer this] array of hints to add, processed in order. Each element has the same fields as the top-level ones below (text/asset_ids/traffic_refs). The returned ids match this array in length and order.", "items": obj(map[string]any{"text": str("hint content"), "asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}}, "traffic_refs": HintTrafficSchema()})}, @@ -1797,13 +1828,13 @@ func (t *ToolSet) addHint() actool.CoreTool { } if len(addedTexts) > 0 { // Wake the planner (once for the whole batch). Prefer notifyHint: one - // add_hint records a single "human added N strategic hints: …" trigger, so - // the planner knows "this round was fired by a new hint" and sees its - // content; when the callback isn't wired, fall back to plain notify (bare - // wake; the hint is still folded into the graph for it to read). + // add_hint records a single " added N strategic hints: …" trigger, + // so the planner knows "this round was fired by a new hint", who wrote it, + // and sees its content; when the callback isn't wired, fall back to plain + // notify (bare wake; the hint is still folded into the graph for it to read). switch { case t.notifyHint != nil: - t.notifyHint(addedTexts) + t.notifyHint(t.worker, addedTexts) case t.notify != nil: t.notify() } diff --git a/guard/guard.go b/guard/guard.go index 0307b7b..e815268 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -38,7 +38,7 @@ type Guard struct { attrib map[string]int // failure attribution counts (Observer / G5) reg *hook.Registry interceptor *intercept.Interceptor // optional; nil disables user-configured rules - targetOnly bool // planner / main agent: target gate only, no rules, no judge, no audit ring + targetOnly bool // planner / main agent: built-in deny rules and target gate only, no user rules, no judge, no audit ring gate targetGate // the target gate; nil without an interceptor } @@ -50,9 +50,10 @@ func New() *Guard { return newGuard(nil) } func NewWithInterceptor(ic *intercept.Interceptor) *Guard { return newGuard(ic) } // NewTargetOnly creates the Guard for the planner and the main agent. Their tool -// calls pass through the target gate only: the intercept rules and the fallback -// judge are not extended to them, and their calls are not written to the audit -// ring (which keeps the worker history of the task's main Guard). +// calls pass through the built-in deny rules and the target gate only: the user +// intercept rules and the fallback judge are not extended to them, and their +// calls that pass are not written to the audit ring (which keeps the worker +// history of the task's main Guard). func NewTargetOnly(ic *intercept.Interceptor) *Guard { g := &Guard{attrib: map[string]int{}, interceptor: ic, targetOnly: true} g.setGate(ic) @@ -131,12 +132,23 @@ func (g *Guard) gateScope(ctx context.Context) (intercept.TargetScope, bool) { // then the target gate. Both rules and the fallback judge receive the complete // tool input. // -// The rules and the judge apply to the enabled tools of a full guard. The -// target gate applies to every tool call of a session whose gate is on: a deny -// blocks first, before any target request exists; an allow by a rule or by the -// judge does not satisfy the gate; an ask and an unpinned target share one -// approval request. +// The rules and the judge apply to the enabled tools of a full guard. A +// target-only guard applies the built-in deny rules alone, whether its gate is +// on or off. The target gate applies to every tool call of a session whose gate +// is on: a deny blocks first, before any target request exists; an allow by a +// rule or by the judge does not satisfy the gate; an ask and an unpinned target +// share one approval request. func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result { + if g.targetOnly && g.interceptor != nil { + // The planner and the main agent run Bash too (#124, F11): the built-in + // deny rules apply to them, before the target gate. User rules and the + // judge stay with the full guard. + if dec, ok := g.interceptor.MatchBuiltin(ev.ToolName, ev.Input); ok { + ctx = intercept.WithCall(ctx, ev.ToolName, ev.Input) + g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "denied") + return g.block(ev.ToolName, systemBlockMessage(dec.Message), "") + } + } scope, gated := g.gateScope(ctx) rules := !g.targetOnly && g.interceptor != nil && g.interceptor.IsToolEnabled(ev.ToolName) if !rules && !gated { diff --git a/guard/guard_test.go b/guard/guard_test.go index b484369..03f2b2c 100644 --- a/guard/guard_test.go +++ b/guard/guard_test.go @@ -3,8 +3,11 @@ package guard import ( "context" "encoding/json" + "strings" "testing" + "time" + "github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/intercept" "github.com/Autumn-27/norma/hook" ) @@ -37,11 +40,14 @@ func TestPreToolUsePassthrough(t *testing.T) { } } -// The target-only guard (planner, main agent) runs the target gate and nothing -// else: no intercept rule, no fallback judge, no audit-ring entry. Here the gate -// is off (no scope, or a scope without the flag). Its interceptor has no database -// behind it, so any read or write through it would panic — passing proves that -// with the gate off every call is allowed without touching the store. +// The target-only guard (planner, main agent) runs the built-in deny rules and +// the target gate: no user intercept rule, no fallback judge, no audit-ring entry +// for a call that passes (in production the built-in rules come from the store, +// see TestTargetOnlyGuardAppliesBuiltinDenyRules). Here the gate is off (no +// scope, or a scope without the flag). Its interceptor has no database behind it +// and no loaded rules, so it matches no built-in rule, and any read or write +// through it would panic — passing proves that with the gate off every call is +// allowed without touching the store. func TestTargetOnlyGuardSkipsRules(t *testing.T) { calls := []struct { tool string @@ -89,4 +95,59 @@ func TestTargetOnlyGuardSkipsRules(t *testing.T) { } } +// The target-only guard applies the built-in deny rules (#124, F11), and no user +// rule. The rules are injected into a store-less interceptor through the +// SetRulesForTest seam, so nothing is read from or logged to a database. +func TestTargetOnlyGuardAppliesBuiltinDenyRules(t *testing.T) { + newIC := func() *intercept.Interceptor { + ic := intercept.New(nil) + ic.SetRulesForTest([]db.InterceptRule{ + {ID: 1, Name: "[built-in] Destructive system commands", Enabled: true, Priority: 100, + MatchTarget: "tool_input", MatchType: "regex", Action: "deny", + Pattern: `(?i)\b(rm\s+-rf\s+/|mkfs|dd\s+if=|:\(\)\s*\{|shutdown|reboot|>\s*/dev/sd)`, + Message: "Destructive command rejected"}, + {ID: 2, Name: "No whoami", Enabled: true, Priority: 50, + MatchTarget: "tool_input", MatchType: "string", Action: "deny", + Pattern: "whoami", Message: "user rule: whoami is forbidden"}, + }) + return ic + } + const platform = "[ARTEX platform control, not target defense]" + + g := NewTargetOnly(newIC()) + gate := &fakeGate{t: t} + g.gate = gate + for name, ctx := range map[string]context.Context{ + "gate off": context.Background(), + "gate on": gateOn(context.Background(), "7", time.Minute), + } { + if block, msg := pre(g, ctx, "Bash", `{"command":"rm -rf /"}`); !block || + !strings.HasPrefix(msg, platform) || !strings.Contains(msg, "Destructive command rejected") { + t.Errorf("%s: rm -rf / block=%v msg=%q, want a built-in deny", name, block, msg) + } + if block, msg := pre(g, ctx, "Bash", `{"command":"ls -la"}`); block { + t.Errorf("%s: a benign command was blocked: %s", name, msg) + } + if block, msg := pre(g, ctx, "Bash", `{"command":"whoami"}`); block { + t.Errorf("%s: a user rule was applied to the target-only guard: %s", name, msg) + } + // A tool outside the rule system is not matched, even when its text names a + // destructive command (a hint or an intent that describes one). + if block, msg := pre(g, ctx, "add_hint", `{"text":"the reset endpoint runs rm -rf / on the target"}`); block { + t.Errorf("%s: a tool outside the rule system was blocked: %s", name, msg) + } + } + // The built-in deny is decided before the gate: only the two passing Bash calls + // and add_hint of the gated round reached it. + if gate.checks != 3 || len(gate.asked) != 0 { + t.Errorf("gate checks=%d requests=%d, want 3 and 0", gate.checks, len(gate.asked)) + } + + // The full guard (workers) still applies the user rule. + full := NewWithInterceptor(newIC()) + if block, msg := pre(full, context.Background(), "Bash", `{"command":"whoami"}`); !block || !strings.Contains(msg, "user rule: whoami is forbidden") { + t.Errorf("full guard: whoami block=%v msg=%q, want the user rule", block, msg) + } +} + var _ = hook.PreToolUse diff --git a/intercept/intercept.go b/intercept/intercept.go index d2482f8..22e2caa 100644 --- a/intercept/intercept.go +++ b/intercept/intercept.go @@ -615,6 +615,74 @@ func (i *Interceptor) Match(toolName string, input []byte) (Decision, bool) { return Decision{}, false } +// builtinRulePrefix marks the rules seeded by the platform (db.seedDefaultInterceptRules +// and its v2/v3 migrations). +const builtinRulePrefix = "[built-in]" + +// MatchBuiltin is Match restricted to the built-in deny rules: the enabled rules +// whose name starts with "[built-in]" and whose action is deny, on the tools that +// enter the rule system. It returns the first match in priority order. User rules, +// allow and ask rules, and the judge are not consulted. The planner and the main +// agent use it (#124, F11). An interceptor without a database and without loaded +// rules matches nothing. +func (i *Interceptor) MatchBuiltin(toolName string, input []byte) (Decision, bool) { + i.mu.RLock() + loaded := i.cached != nil + i.mu.RUnlock() + if !loaded && i.db == nil { + return Decision{}, false + } + if !i.IsToolEnabled(toolName) { + return Decision{}, false + } + rules, err := i.rules() + if err != nil { + return Decision{}, false + } + for _, r := range rules { + if r.Action != "deny" || !strings.HasPrefix(r.Name, builtinRulePrefix) || !ruleMatches(r, toolName, input) { + continue + } + msg := r.Message + if msg == "" { + msg = defaultMessage(r.Action, r.Name) + } + configJSON, _ := json.Marshal(r.InterceptRule) + return Decision{ + RuleName: r.Name, ConfigDigest: digestInput(configJSON), + Action: r.Action, + Message: msg, + RuleID: r.ID, + }, true + } + return Decision{}, false +} + +// SetRulesForTest installs rules (in priority order) as the loaded rule set, with +// the default intercept-enabled tools, on an interceptor that may have no +// database. It is a test seam for the guard package, which cannot reach the +// unexported cache; production code loads the rules from the database. +func (i *Interceptor) SetRulesForTest(rules []db.InterceptRule) { + out := []compiledRule{} + for _, r := range rules { + if !r.Enabled { + continue + } + cr := compiledRule{InterceptRule: r} + if r.MatchType == "regex" { + cr.re = regexp.MustCompile(r.Pattern) + } + out = append(out, cr) + } + tools := make(map[string]bool, len(defaultEnabledTools)) + for _, n := range defaultEnabledTools { + tools[n] = true + } + i.mu.Lock() + i.cached, i.enabledTools = out, tools + i.mu.Unlock() +} + func ruleMatches(r compiledRule, toolName string, input []byte) bool { var subject string switch r.MatchTarget { @@ -649,6 +717,9 @@ func defaultMessage(action, name string) string { // error is swallowed so logging never changes the tool call's outcome. The pending list // (status='pending') is unaffected, so it still shows only asks awaiting a decision. func (i *Interceptor) Log(ctx context.Context, convID int64, dec Decision, toolName string, input []byte, status string) { + if i.db == nil { + return // no store to record into (SetRulesForTest) + } taskID, agentName := taskInfoFromCtx(ctx) audit := auditFor(ctx, dec, input, status) id, err := i.db.CreateDecidedIntercept(dec.RuleID, convID, taskID, agentName, toolName, input, status, dec.Message, audit) diff --git a/server/manager.go b/server/manager.go index 24ec67e..726872a 100644 --- a/server/manager.go +++ b/server/manager.go @@ -1888,16 +1888,17 @@ func (t *Task) NotifyGoal(texts []string) { } // NotifyHint records that one OR MORE hints were added in a single add_hint call — -// by the human via the main agent, or by cross-task orchestration — then wakes the -// planner, so the next round is told "the human added N strategy hints: ..." and looks at them -// directly instead of having to spot the new hint folded into the graph overview. +// by the human via the main agent (origin "human"), or by cross-task orchestration +// (origin "orchestrator") — then wakes the planner, so the next round is told who +// added N strategy hints and looks at them directly instead of having to spot the +// new hint folded into the graph overview. // One call → one trigger event (a batched add_hint counts as one, not one per hint). -func (t *Task) NotifyHint(texts []string) { +func (t *Task) NotifyHint(origin string, texts []string) { if len(texts) == 0 { return } t.trigMu.Lock() - t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "hint", Hints: texts}) + t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "hint", Hints: texts, HintOrigin: origin}) t.trigMu.Unlock() t.Notify() } diff --git a/server/orchestration.go b/server/orchestration.go index dec9d83..266d361 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -170,7 +170,7 @@ func (s *Server) delegateToTask(ctx context.Context, in json.RawMessage, pick fu tsx.SetAssetStore(s.m.Assets(), s.m.Assets().Companies()) } tsx.SetNotify(t.Notify) // generic wake-up (write ops without a dedicated callback go through it; no-op for read tools) - tsx.SetNotifyHint(t.NotifyHint) // add_hint → records a "human added N strategic hints: ..." entry and wakes the planner + tsx.SetNotifyHint(t.NotifyHint) // add_hint → records an "orchestrator added N strategic hints: ..." entry and wakes the planner return pick(tsx).Call(ctx, inner, nil) }