feat: enable the untrusted-output boundary with ARTEX_UNTRUSTED_BOUNDARY

Norma v0.4.6 frames tool output between nonce-tagged markers, so the model
reads it as data. ARTEX passes that option from the environment: 1, true or
on enable it (case and surrounding spaces ignored); unset or anything else
keeps it off, which is the default. Nothing changes unless the variable is set.

The option is wired into the four agentcore.Options builders that set
ToolOutputDir: worker, planner, chat and the main agent. .env.example
documents the variable, commented out, with a note to test it on a local
target before enabling it in production.

Not included: docker-compose.yml does not pass the variable to the artex
service, so it has no effect under compose until it is listed there.
This commit is contained in:
BaaRai
2026-11-09 23:42:49 +01:00
parent a83f494d03
commit c73e2872a3
7 changed files with 95 additions and 0 deletions
+5
View File
@@ -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
+3
View File
@@ -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()
+3
View File
@@ -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
+3
View File
@@ -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
+19
View File
@@ -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
}
+59
View File
@@ -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)
}
}
}
+3
View File
@@ -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