diff --git a/.env.example b/.env.example index dcbcd63..1308290 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,8 @@ ARTEX_LLM_PROVIDER= ARTEX_LLM_MODEL= ARTEX_LLM_BASE_URL= ARTEX_LLM_PROXY= + +# Untrusted-output boundary (optional). ARTEX_UNTRUSTED_BOUNDARY=1 frames every tool +# output between nonce-tagged markers so the model treats it as data, not instructions. +# Off by default; test it against a local target before enabling it in production. +# ARTEX_UNTRUSTED_BOUNDARY=1 diff --git a/agent/chat.go b/agent/chat.go index ae52ee8..02ec298 100644 --- a/agent/chat.go +++ b/agent/chat.go @@ -160,6 +160,9 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin 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() diff --git a/agent/mainagent.go b/agent/mainagent.go index 94585fb..f5b3f7d 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -169,6 +169,9 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db. Settlement: wrapupSettlement("mainagent", nil), NonStreaming: m.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete MaxTokens: m.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 m.tx != nil { // persist raw human↔AI conversation; one accumulating file per segment opts.Transcript = m.tx diff --git a/agent/planner.go b/agent/planner.go index 7f53f80..ddf16be 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -457,6 +457,9 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts Settlement: settle, NonStreaming: p.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete MaxTokens: p.maxTokens(), // 0 = send no cap; the server default decides + + // Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data. + UntrustedOutputBoundary: untrustedBoundaryEnabled(), } if p.tx != nil { // persist raw LLM conversation; one accumulating file per task's planner opts.Transcript = p.tx diff --git a/agent/untrusted_boundary.go b/agent/untrusted_boundary.go new file mode 100644 index 0000000..cf20e93 --- /dev/null +++ b/agent/untrusted_boundary.go @@ -0,0 +1,19 @@ +package agent + +import ( + "os" + "strings" +) + +// untrustedBoundaryEnabled reports whether ARTEX_UNTRUSTED_BOUNDARY opts in to +// Norma's untrusted-output boundary (agentcore.Options.UntrustedOutputBoundary: +// tool results framed between nonce-tagged markers). Only "1", "true" and "on" +// (case- and space-insensitive) enable it; unset, empty or anything else keeps +// it off. +func untrustedBoundaryEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_UNTRUSTED_BOUNDARY"))) { + case "1", "true", "on": + return true + } + return false +} diff --git a/agent/untrusted_boundary_test.go b/agent/untrusted_boundary_test.go new file mode 100644 index 0000000..c396eb6 --- /dev/null +++ b/agent/untrusted_boundary_test.go @@ -0,0 +1,59 @@ +package agent + +import ( + "os" + "strings" + "testing" +) + +func TestUntrustedBoundaryEnabled(t *testing.T) { + for _, tc := range []struct { + env string + want bool + }{ + {"", false}, + {"0", false}, + {"false", false}, + {"yes", false}, + {"1", true}, + {"true", true}, + {"on", true}, + {" TRUE ", true}, + {"On", true}, + } { + t.Setenv("ARTEX_UNTRUSTED_BOUNDARY", tc.env) + if got := untrustedBoundaryEnabled(); got != tc.want { + t.Errorf("ARTEX_UNTRUSTED_BOUNDARY=%q: got %v, want %v", tc.env, got, tc.want) + } + } +} + +func TestUntrustedBoundaryDisabledWhenUnset(t *testing.T) { + t.Setenv("ARTEX_UNTRUSTED_BOUNDARY", "1") // registers restoration of the original value + os.Unsetenv("ARTEX_UNTRUSTED_BOUNDARY") + if untrustedBoundaryEnabled() { + t.Fatal("ARTEX_UNTRUSTED_BOUNDARY unset: got true, want false") + } +} + +// Structural, not behavioural: the four agentcore.Options literals live inside +// run methods that need a database and an LLM provider, so this only checks +// that each source file passes the helper to the option exactly once. +func TestUntrustedBoundaryWiredInOptionsLiterals(t *testing.T) { + const want = "UntrustedOutputBoundary: untrustedBoundaryEnabled()," + for _, f := range []string{"worker.go", "planner.go", "chat.go", "mainagent.go"} { + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + n := 0 + for _, line := range strings.Split(string(src), "\n") { + if strings.TrimSpace(line) == want { + n++ + } + } + if n != 1 { + t.Errorf("%s: %d lines equal to %q, want 1", f, n, want) + } + } +} diff --git a/agent/worker.go b/agent/worker.go index 93b5863..d69d003 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -526,6 +526,9 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db. Todos: actool.NewTodoStore(), // session-level scratch todos (TodoWrite), pure planning, discarded on exit NonStreaming: w.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete MaxTokens: w.maxTokens(), // 0 = send no cap; the server default decides + + // Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data. + UntrustedOutputBoundary: untrustedBoundaryEnabled(), } if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic) opts.Hooks = hooks diff --git a/docker-compose.yml b/docker-compose.yml index 3870ea7..d343b43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: ARTEX_LLM_PROXY: ${ARTEX_LLM_PROXY:-} ARTEX_JWT_KEY_DIR: /app/keys ARTEX_TRUSTED_PROXIES: ${ARTEX_TRUSTED_PROXIES:-} + ARTEX_UNTRUSTED_BOUNDARY: ${ARTEX_UNTRUSTED_BOUNDARY:-} ports: - "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787" - "127.0.0.1:8788:8788"