mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Phase B, part 1 of the agentic attack-class audit (issue #124). - Planner and cold-digest compactor: a code-owned untrusted-data tail after the rendered body. Worker summaries, facts, digests, traces and tool outputs are data; they cannot add goals, targets or scope, end the task, or be relayed through steer_work as instructions. - The #49 untrusted-content block moves out of the editable section [A] of the worker, mainagent, auto and pentest seeds into a code-owned tail, also applied to the reporter. A body that already carries the block verbatim (rows seeded before this change) does not get it twice. - Planner user message: a code-owned goal anchor after the situational block restates the task goal and the operation constraints and asks each new intent to name the goal id it serves. The message builder is factored into plannerRoundInput for pure tests. - bbot-recon skill: every event field is untrusted; only scope_distance == 0 reaches insert_assets; scope_distance > 0 stays an intel fact with source=third_party_intel, never auto-scoped; FINDING text is a lead. Tests: prompt_injection_seed_test.go checks that a PromptOverride without the block still renders the tail for every role, planner and compactor included, that the block is not duplicated, and that the planner input ends with the goal anchor. shipped_skills_test.go checks the bbot markers.
199 lines
9.5 KiB
Go
199 lines
9.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/guard"
|
|
"github.com/Autumn-27/artex/intercept"
|
|
"github.com/Autumn-27/norma/agentcore"
|
|
"github.com/Autumn-27/norma/llm"
|
|
"github.com/Autumn-27/norma/permission"
|
|
actool "github.com/Autumn-27/norma/tool"
|
|
"github.com/Autumn-27/norma/transcript"
|
|
)
|
|
|
|
// ChatAgent is the generic, task-independent conversational runner behind the chat
|
|
// page. It generalizes MainAgent.Chat: any agent (built-in OR a custom one, by
|
|
// key) can be chatted with, multi-turn history resumed from the transcript. It is
|
|
// a PURE ASSISTANT — base tools are the SDK DefaultTools (Bash/Read/Write/Edit/
|
|
// LS/Glob/Grep) plus whatever skills/MCP the key is made visible; NO pentest
|
|
// graph/task context is injected (that stays exclusive to MainAgent).
|
|
type ChatAgent struct {
|
|
prov llm.Provider
|
|
model string
|
|
workDir string
|
|
tx *transcript.Store
|
|
window int
|
|
proxyAddr string
|
|
proxyCACert string
|
|
webSearch WebSearchOpts
|
|
guard *guard.Guard // optional; nil disables intercept hooks for chat
|
|
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
|
|
noaEnabledFn func() bool // resolver: use experimental noa compaction? (nil = off)
|
|
maxTokensFn func() int // resolver: per-reply output cap (nil/0 = send no cap)
|
|
}
|
|
|
|
func NewChatAgent(prov llm.Provider, model, workDir string, tx *transcript.Store, window int) *ChatAgent {
|
|
return &ChatAgent{prov: prov, model: model, workDir: workDir, tx: tx, window: window}
|
|
}
|
|
|
|
// SetNonStreaming wires a resolver deciding whether chat runs use the
|
|
// non-streaming model path (true = non-streaming). nil/unset = streaming.
|
|
func (c *ChatAgent) SetNonStreaming(fn func() bool) { c.nonStreamingFn = fn }
|
|
|
|
func (c *ChatAgent) nonStreaming() bool { return c.nonStreamingFn != nil && c.nonStreamingFn() }
|
|
|
|
// SetNoaEnabled wires a resolver deciding whether chat runs use the experimental
|
|
// noa context-compression mechanism. nil/unset = off (built-in compaction). Read
|
|
// per run so the settings toggle takes effect without rebuilding the agent.
|
|
func (c *ChatAgent) SetNoaEnabled(fn func() bool) { c.noaEnabledFn = fn }
|
|
|
|
// SetMaxTokens wires a resolver for the per-reply output cap. nil/unset or 0 =
|
|
// send no cap and let the endpoint decide. Read per run, like nonStreaming.
|
|
func (c *ChatAgent) SetMaxTokens(fn func() int) { c.maxTokensFn = fn }
|
|
|
|
func (c *ChatAgent) maxTokens() int {
|
|
if c.maxTokensFn == nil {
|
|
return 0
|
|
}
|
|
return c.maxTokensFn()
|
|
}
|
|
|
|
// SetProxy points the chat agent's WebFetch/Bash at the recording proxy plus the
|
|
// CA cert it trusts (empty addr = direct). Kept for parity with the other agents.
|
|
func (c *ChatAgent) SetProxy(addr, caCert string) { c.proxyAddr, c.proxyCACert = addr, caCert }
|
|
|
|
// SetWebSearch selects the web_search backend for the chat agent (off by default).
|
|
func (c *ChatAgent) SetWebSearch(o WebSearchOpts) { c.webSearch = o }
|
|
|
|
// SetGuard attaches a guard (with user-configured intercept rules) to this chat
|
|
// agent. Must be called before Chat; safe to call multiple times.
|
|
func (c *ChatAgent) SetGuard(g *guard.Guard) { c.guard = g }
|
|
|
|
// chatWorkDirSpec returns a working-directory notice appended to every chat
|
|
// agent's system prompt. Mirrors artifactSpec but without pentest-specific
|
|
// wording ("payload", "capture response bodies") that would be odd in a general assistant.
|
|
func chatWorkDirSpec(workDir string) string {
|
|
return "\n\n**File output rules**: whenever you need to write a file, write it to the working directory " + workDir + " (this is the default CWD — relative paths land here, and you may also use this absolute path) — do not write to /tmp or other absolute paths."
|
|
}
|
|
|
|
// chatSystem renders the DB-managed prompt body for agentKey. Custom agents have
|
|
// no per-key in-code default, so DefaultAssistantPrompt is the render fallback.
|
|
// The auto, pentest and reporter agents read target-derived content, so they get
|
|
// the code-owned untrusted-content tail after the editable body.
|
|
func chatSystem(agentKey, dataDir, workDir string) string {
|
|
body := renderSystem(agentKey, DefaultAssistantPrompt, chatVars{DataDir: dataDir, Now: nowStr()})
|
|
switch agentKey {
|
|
case "auto", "pentest", "reporter":
|
|
body += untrustedContentTail(body)
|
|
}
|
|
return body + chatWorkDirSpec(workDir)
|
|
}
|
|
|
|
// chatVars carries the runtime variables a custom agent's prompt may reference.
|
|
// DataDir (server data root) + Now (server wall-clock, refreshed each turn) are the
|
|
// universal ones; any other {{.X}} fails to render and falls back to
|
|
// DefaultAssistantPrompt.
|
|
type chatVars struct{ DataDir, Now string }
|
|
|
|
// Chat runs ONE turn of a conversation with the agent identified by agentKey,
|
|
// resuming prior history keyed by sessionID. maxTurns is the per-turn agent step
|
|
// budget (0 = unlimited). maxDuration is the wall-clock run budget per turn
|
|
// (0 = unlimited); the timer resets each time Chat is called, so a new user
|
|
// message always starts a fresh countdown. webSearch gates network search for
|
|
// THIS agent (the global backend/key still come from the chat agent's config,
|
|
// but each agent decides on/off). emit receives each execution step (thinking /
|
|
// tool_use / tool_result / text / result), tagged with the agent key as the
|
|
// worker lane.
|
|
func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message string, maxTurns int, maxDuration time.Duration, webSearch bool, emit func(db.Activity)) (string, error) {
|
|
// gate the global web-search opts by this agent's own flag.
|
|
ws := c.webSearch
|
|
if !webSearch {
|
|
ws.Enabled = false
|
|
}
|
|
|
|
// Per-session working directory: <workDir>/sessions/<sessionID>/
|
|
// Isolates file writes across conversations, mirroring how workers use i<intentID>/.
|
|
sessionWorkDir := filepath.Join(c.workDir, "sessions", sessionID)
|
|
_ = os.MkdirAll(sessionWorkDir, 0o755)
|
|
ctx = intercept.WithReviewWorkingDirectory(ctx, sessionWorkDir)
|
|
|
|
// Pure assistant: DefaultTools as the base; AugmentTools layers in the key's
|
|
// visible skills/MCP and lets the DB tools table filter/override. DefaultTools
|
|
// have no tools-table rows, so they always pass through.
|
|
base := actool.DefaultTools()
|
|
ctx = WithRunInfo(ctx, RunInfo{SessionID: sessionID})
|
|
tools, def, cleanup := AugmentTools(ctx, agentKey, base)
|
|
defer cleanup()
|
|
|
|
system, boundary := deferredSystem(chatSystem(agentKey, c.workDir, sessionWorkDir), def)
|
|
opts := agentcore.Options{
|
|
Provider: c.prov,
|
|
SystemPrompt: system,
|
|
DynamicBoundary: boundary,
|
|
Tools: withWebFetch(ctx, tools, c.proxyAddr, c.proxyCACert), // WebFetch: routed through the recording proxy for tracing; loads the proxy CA to verify MITM-resigned HTTPS certificates; every redirect hop is gated
|
|
DeferredTools: def.Deferred,
|
|
UnlockSet: def.Unlock,
|
|
PermissionMode: permission.ModeBypass,
|
|
// Web search (optional). ddgs needs no key; brave-free needs BraveKey; tavily needs TavilyKey.
|
|
// WebSearchProxy is a dedicated egress proxy (http/https/socks5), unrelated to the traffic-recording MITM proxy; empty = direct.
|
|
EnableWebSearch: ws.Enabled,
|
|
WebSearchBackend: ws.Backend,
|
|
BraveSearchAPIKey: ws.BraveKey,
|
|
TavilySearchAPIKey: ws.TavilyKey,
|
|
DeepSeekSearchBaseURL: ws.DeepSeekBaseURL,
|
|
DeepSeekSearchAPIKey: ws.DeepSeekAPIKey,
|
|
DeepSeekSearchModel: ws.DeepSeekModel,
|
|
WebSearchProxy: ws.Proxy,
|
|
BashEnv: proxyEnv(c.proxyAddr, c.proxyCACert), // Bash subcommands go through the proxy + trust the CA by default
|
|
WorkingDir: sessionWorkDir,
|
|
MaxTurns: maxTurns,
|
|
MaxDuration: maxDuration,
|
|
Compaction: compactionConfig(c.window),
|
|
Todos: actool.NewTodoStore(),
|
|
// large tool output spills to cmd-output/ under the session dir.
|
|
// the truncation cap uses the SDK default (30000 chars of tool.Capture).
|
|
ToolOutputDir: filepath.Join(sessionWorkDir, "cmd-output"),
|
|
// hitting the budget (steps) → the SDK runs wrap-up: output a one-line summary. The prompt and
|
|
// wrap-up rounds are editable in the backend per agent key
|
|
// (each custom agent gets its own copy; empty/0 uses the generic default: 10 rounds).
|
|
Settlement: wrapupSettlement(agentKey, nil),
|
|
NonStreaming: c.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
|
MaxTokens: c.maxTokens(), // 0 = send no cap, the server's default decides
|
|
|
|
// Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data.
|
|
UntrustedOutputBoundary: untrustedBoundaryEnabled(),
|
|
}
|
|
if c.guard != nil {
|
|
opts.Hooks = c.guard.Hooks()
|
|
}
|
|
if c.tx != nil { // persist raw human↔AI conversation; one accumulating file per thread
|
|
opts.Transcript = c.tx
|
|
opts.SessionID = sessionID
|
|
}
|
|
// experimental feature: when enabled, noa takes over context compression (archive centralized under <workDir>/noa/<SessionID>, persistent).
|
|
enableNoa(&opts, c.noaEnabledFn, c.workDir, "chat-"+sessionID, noaWarn("chat-"+sessionID))
|
|
ctx = attachSideCapture(ctx, &opts)
|
|
s := agentcore.NewSession(opts)
|
|
defer s.Close()
|
|
// reload prior conversation so the agent has context across turns (each Chat is
|
|
// a fresh session). First turn: no file yet → Resume loads nothing and proceeds.
|
|
if c.tx != nil {
|
|
_ = s.Resume(sessionID)
|
|
}
|
|
// re-unlock skill-gated MCPs from prior Skill() calls in the reloaded history so
|
|
// revealed tools stay callable across the fresh session.
|
|
seedUnlockFromHistory(s.Messages(), def.UnlockSkill)
|
|
text, _, err := captureRunSession(ctx, s, message, func(r db.Activity) {
|
|
if emit != nil {
|
|
r.Worker = agentKey
|
|
emit(r)
|
|
}
|
|
})
|
|
return text, err
|
|
}
|