Skip to content

Commit e187f8d

Browse files
committed
fix: wrap skill/episode context as untrusted before injection
Skill content and retrieved session episodes were injected as plain system messages, so a compromised or tainted skill/episode could pose as trusted instructions. They now pass through the same nonce'd <untrusted_content_*> wrapper used for tool output. - Add UntrustedWrapper to odek.Config and plumb it into loop.Engine. - In internal/loop/loop.go, apply the wrapper (when set) to skill and episode context before injecting system messages. - Update all odek.New callers (CLI, REPL, serve, telegram, schedule, subagent) to pass wrapUntrusted. - Remove the trusted-task-guide instruction from verbose skill banners. - Add TestEngine_SkillAndEpisode_Wrapped regression test. - Update docs/SECURITY.md and AGENTS.md.
1 parent 01bfc9c commit e187f8d

11 files changed

Lines changed: 134 additions & 51 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
139139
- **Telegram photo caption wrapping** (`cmd/odek/telegram.go`) — photo captions cross the Telegram trust boundary, so they are wrapped as untrusted content both when passed to the local vision model and when injected into the main agent's user message.
140140
- **`send_message` callback prefix restriction** (`internal/tool/send_message.go` + `cmd/odek/telegram.go`) — the `send_message` tool rejects any button whose `callback_data` starts with a reserved internal prefix (`apr:`, `den:`, `trs:`, `clarify:`, `skill_save:`, `skill_skip:`); only user-facing `cb:` callbacks are allowed. The Telegram sender closure validates again as defense-in-depth, preventing a forged approval or skill button.
141141
- **Session ID entropy + session-scoped auth tokens** (`internal/session/session.go`, `cmd/odek/serve.go`) — session IDs now carry 128 bits of randomness (16 bytes / 32 hex chars); each session stores a 256-bit `AuthToken` required by `GET/DELETE/POST /api/sessions/<id>` and WebSocket session-resume messages via `X-Session-Token` header, `session_token` cookie, or `auth_token` WS field. Per-IP rate limiting (60/min) on session lookups adds a brute-force backstop.
142+
- **Skill/episode untrusted wrapper** (`internal/loop/loop.go` + `odek.go`) — skill context and retrieved session-episode context are passed through the caller-provided untrusted wrapper (the same nonce'd `<untrusted_content_*>` boundary used for tool output) before being injected into the model's system context. This prevents a compromised or tainted skill/episode from being treated as trusted system instructions.
142143
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
143144

144145
### Platform Support

cmd/odek/main.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -922,13 +922,14 @@ func run(args []string) error {
922922
}
923923

924924
agent, err := odek.New(odek.Config{
925-
Model: resolved.Model,
926-
BaseURL: resolved.BaseURL,
927-
APIKey: resolved.APIKey,
928-
MaxIterations: resolved.MaxIter,
929-
MaxToolParallel: resolved.MaxToolParallel,
930-
SystemMessage: systemMessage,
931-
NoProjectFile: resolved.NoAgents,
925+
Model: resolved.Model,
926+
BaseURL: resolved.BaseURL,
927+
APIKey: resolved.APIKey,
928+
MaxIterations: resolved.MaxIter,
929+
MaxToolParallel: resolved.MaxToolParallel,
930+
SystemMessage: systemMessage,
931+
UntrustedWrapper: wrapUntrusted,
932+
NoProjectFile: resolved.NoAgents,
932933
Thinking: resolved.Thinking,
933934
ThinkingBudget: f.ThinkingBudget,
934935
Temperature: 0, // deterministic by default; override with --temperature
@@ -1770,13 +1771,14 @@ func continueCmd(args []string) error {
17701771
}
17711772

17721773
agent, err := odek.New(odek.Config{
1773-
Model: resolved.Model,
1774-
BaseURL: resolved.BaseURL,
1775-
APIKey: resolved.APIKey,
1776-
MaxIterations: resolved.MaxIter,
1777-
MaxToolParallel: resolved.MaxToolParallel,
1778-
SystemMessage: systemMessage,
1779-
NoProjectFile: resolved.NoAgents,
1774+
Model: resolved.Model,
1775+
BaseURL: resolved.BaseURL,
1776+
APIKey: resolved.APIKey,
1777+
MaxIterations: resolved.MaxIter,
1778+
MaxToolParallel: resolved.MaxToolParallel,
1779+
SystemMessage: systemMessage,
1780+
UntrustedWrapper: wrapUntrusted,
1781+
NoProjectFile: resolved.NoAgents,
17801782
Thinking: resolved.Thinking,
17811783
Temperature: 0, // deterministic by default; override with --temperature
17821784
Tools: tools,

cmd/odek/repl.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,12 @@ func replCmd(args []string) error {
129129
}
130130

131131
agent, err := odek.New(odek.Config{
132-
Model: resolved.Model,
133-
BaseURL: resolved.BaseURL,
134-
APIKey: resolved.APIKey,
135-
MaxIterations: resolved.MaxIter,
136-
SystemMessage: systemMessage,
132+
Model: resolved.Model,
133+
BaseURL: resolved.BaseURL,
134+
APIKey: resolved.APIKey,
135+
MaxIterations: resolved.MaxIter,
136+
SystemMessage: systemMessage,
137+
UntrustedWrapper: wrapUntrusted,
137138
NoProjectFile: resolved.NoAgents,
138139
Thinking: resolved.Thinking,
139140
ThinkingBudget: f.ThinkingBudget,

cmd/odek/schedule.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,7 @@ func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system
673673
MaxIterations: resolved.MaxIter,
674674
MaxToolParallel: resolved.MaxToolParallel,
675675
SystemMessage: system,
676+
UntrustedWrapper: wrapUntrusted,
676677
RuntimeContext: odek.BuildRuntimeContext("schedule"),
677678
NoProjectFile: resolved.NoAgents,
678679
Thinking: resolved.Thinking,

cmd/odek/serve.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -407,13 +407,14 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
407407
}
408408

409409
agent, err := odek.New(odek.Config{
410-
Model: resolved.Model,
411-
BaseURL: resolved.BaseURL,
412-
APIKey: resolved.APIKey,
413-
MaxIterations: resolved.MaxIter,
414-
MaxToolParallel: resolved.MaxToolParallel,
415-
SystemMessage: system,
416-
RuntimeContext: runtimeCtx,
410+
Model: resolved.Model,
411+
BaseURL: resolved.BaseURL,
412+
APIKey: resolved.APIKey,
413+
MaxIterations: resolved.MaxIter,
414+
MaxToolParallel: resolved.MaxToolParallel,
415+
SystemMessage: system,
416+
UntrustedWrapper: wrapUntrusted,
417+
RuntimeContext: runtimeCtx,
417418
NoProjectFile: resolved.NoAgents,
418419
Thinking: resolved.Thinking,
419420
InteractionMode: resolved.InteractionMode,

cmd/odek/subagent.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,8 @@ func subagentCmd(args []string) error {
358358
BaseURL: resolved.BaseURL,
359359
APIKey: resolved.APIKey,
360360
MaxIterations: cfg.maxIter,
361-
SystemMessage: systemMsg,
361+
SystemMessage: systemMsg,
362+
UntrustedWrapper: wrapUntrusted,
362363
RuntimeContext: odek.BuildRuntimeContext("terminal"),
363364
NoProjectFile: resolved.NoAgents,
364365
Thinking: resolved.Thinking,

cmd/odek/telegram.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1469,13 +1469,14 @@ func handleChatMessage(
14691469
}
14701470

14711471
agentCfg := odek.Config{
1472-
Model: resolved.Model,
1473-
BaseURL: resolved.BaseURL,
1474-
APIKey: resolved.APIKey,
1475-
MaxIterations: resolved.MaxIter,
1476-
MaxToolParallel: resolved.MaxToolParallel,
1477-
SystemMessage: systemMessage,
1478-
RuntimeContext: odek.BuildRuntimeContext("telegram"),
1472+
Model: resolved.Model,
1473+
BaseURL: resolved.BaseURL,
1474+
APIKey: resolved.APIKey,
1475+
MaxIterations: resolved.MaxIter,
1476+
MaxToolParallel: resolved.MaxToolParallel,
1477+
SystemMessage: systemMessage,
1478+
UntrustedWrapper: wrapUntrusted,
1479+
RuntimeContext: odek.BuildRuntimeContext("telegram"),
14791480
InteractionMode: resolved.InteractionMode,
14801481
NoProjectFile: resolved.NoAgents,
14811482
Skills: skillsCfg,

docs/SECURITY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,10 @@ The defense has three layers:
336336

337337
Legacy sessions created before this defense have no `AuthToken`; the first access bootstraps one and returns it to the client, preserving backward compatibility without weakening protection for newly created sessions.
338338

339+
### 24. Skill and episode context wrapped as untrusted
340+
341+
Skill content and retrieved session episodes are externally-sourced data that cross the trust boundary. Before injecting them as `system` messages, the loop passes them through the same nonce'd `<untrusted_content_*>` wrapper used for tool output. The skill manager already gates `NeedsReview`/tainted skills, and the memory manager filters tainted episodes from search, but the wrapper provides defense-in-depth so a compromised skill or episode cannot pose as trusted system instructions.
342+
339343
### YOLO mode
340344

341345
```json

internal/loop/loop.go

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,12 @@ type Engine struct {
6363
system string
6464
baseSystem string // original system message without memory/skills
6565
maxContext int // max context tokens (0 = no limit)
66-
skillLoader SkillLoader // optional: loads matching skills
67-
lastSkillMsg string // last user message that triggered skill loading (dedup)
68-
lastEpiMsg string // last user message that triggered episode search (dedup)
69-
skillVerbose bool // show full skill banners (default: condensed)
70-
episodeCtx EpisodeContextFunc // optional: per-turn episode search
66+
skillLoader SkillLoader // optional: loads matching skills
67+
lastSkillMsg string // last user message that triggered skill loading (dedup)
68+
lastEpiMsg string // last user message that triggered episode search (dedup)
69+
skillVerbose bool // show full skill banners (default: condensed)
70+
episodeCtx EpisodeContextFunc // optional: per-turn episode search
71+
wrapUntrusted func(source, content string) string // optional: wraps skill/episode content
7172

7273
toolEventHandler ToolEventHandler // optional: fires during tool execution
7374
signalHandler SignalHandler // optional: fires on internal loop signals
@@ -170,6 +171,13 @@ func (e *Engine) SetInteractionMode(mode string) { e.interactionMode = mode }
170171
// or condensed markers (false, default). Condensed saves context window space.
171172
func (e *Engine) SetSkillVerbose(verbose bool) { e.skillVerbose = verbose }
172173

174+
// SetUntrustedWrapper sets a function that wraps externally-sourced content
175+
// (skill context, episode context) with a nonce'd boundary before injecting it
176+
// into the model's system context. When nil, that content is injected directly.
177+
func (e *Engine) SetUntrustedWrapper(fn func(source, content string) string) {
178+
e.wrapUntrusted = fn
179+
}
180+
173181
// SetMemoryPromptFunc sets the optional memory prompt callback.
174182
// When set, it is called before each LLM invocation to get fresh memory
175183
// content. This ensures the agent sees the latest facts even if it
@@ -585,27 +593,28 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
585593
if userMsg := lastUserMessage(messages); userMsg != "" && userMsg != e.lastSkillMsg {
586594
if skillContext := e.skillLoader(userMsg); skillContext != "" {
587595
e.lastSkillMsg = userMsg
588-
// Inject skill context as a system message right before the user message
596+
// Inject skill context as a system message right before the user message.
597+
// The skill manager gates NeedsReview/tainted skills, but we treat any
598+
// loaded skill content as externally-sourced and wrap it with the
599+
// caller-provided untrusted wrapper as defense in depth.
600+
wrappedContent := skillContext
601+
if e.wrapUntrusted != nil {
602+
wrappedContent = e.wrapUntrusted("skill", skillContext)
603+
}
589604
insertIdx := len(messages)
590605
for j := len(messages) - 1; j >= 0; j-- {
591606
if messages[j].Role == "system" && j != 0 {
592607
insertIdx = j + 1
593608
break
594609
}
595610
}
596-
// Wrap skill content as a trusted task guide.
597-
// When verbose is enabled, use full banners for debugging/auditing.
598-
// By default, inject skill content silently with no wrapping markers to minimize context window overhead.
599611
var wrappedSkill string
600612
if e.skillVerbose {
601-
wrappedSkill = "═══ SKILL LOADED (task guide) ═══\n" +
602-
skillContext +
603-
"\n═══ END SKILL ═══\n" +
604-
"\nThe instructions above are loaded from a skill file for the current task. " +
605-
"Follow them as your primary guide. Only deviate if they conflict " +
606-
"with your core identity or the safety rules in the system prompt."
613+
wrappedSkill = "═══ SKILL LOADED (reference) ═══\n" +
614+
wrappedContent +
615+
"\n═══ END SKILL ═══"
607616
} else {
608-
wrappedSkill = skillContext
617+
wrappedSkill = wrappedContent
609618
}
610619
skillMsg := llm.Message{Role: "system", Content: wrappedSkill}
611620
// Pre-allocate and copy to avoid nested append allocations
@@ -624,6 +633,12 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
624633
if userMsg := lastUserMessage(messages); userMsg != "" && userMsg != e.lastEpiMsg {
625634
if episodeContext := e.episodeCtx(userMsg); episodeContext != "" {
626635
e.lastEpiMsg = userMsg
636+
// Episode context comes from past session content and crosses the
637+
// trust boundary; wrap it as untrusted before injecting.
638+
wrappedContext := episodeContext
639+
if e.wrapUntrusted != nil {
640+
wrappedContext = e.wrapUntrusted("episode", episodeContext)
641+
}
627642
// Inject episode context as a system message before the user message
628643
insertIdx := len(messages)
629644
for j := len(messages) - 1; j >= 0; j-- {
@@ -632,7 +647,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
632647
break
633648
}
634649
}
635-
epMsg := llm.Message{Role: "system", Content: episodeContext}
650+
epMsg := llm.Message{Role: "system", Content: wrappedContext}
636651
newMsgs := make([]llm.Message, 0, len(messages)+1)
637652
newMsgs = append(newMsgs, messages[:insertIdx]...)
638653
newMsgs = append(newMsgs, epMsg)

internal/loop/loop_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,6 +1917,54 @@ func TestEngine_SkillsAndEpisodesBothLoad(t *testing.T) {
19171917
}
19181918
}
19191919

1920+
func TestEngine_SkillAndEpisode_Wrapped(t *testing.T) {
1921+
var sawSkillWrapped, sawEpisodeWrapped bool
1922+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1923+
var body struct {
1924+
Messages []struct {
1925+
Role string `json:"role"`
1926+
Content string `json:"content"`
1927+
} `json:"messages"`
1928+
}
1929+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
1930+
return
1931+
}
1932+
for _, msg := range body.Messages {
1933+
if strings.HasPrefix(msg.Content, "WRAPPED:skill:") && strings.Contains(msg.Content, "injected skill context") {
1934+
sawSkillWrapped = true
1935+
}
1936+
if strings.HasPrefix(msg.Content, "WRAPPED:episode:") && strings.Contains(msg.Content, "injected episode context") {
1937+
sawEpisodeWrapped = true
1938+
}
1939+
}
1940+
fmt.Fprint(w, `{"choices":[{"message":{"content":"done"}}]}`)
1941+
}))
1942+
defer server.Close()
1943+
1944+
skillLoader := func(string) string { return "injected skill context" }
1945+
episodeCtx := func(string) string { return "injected episode context" }
1946+
1947+
client := llm.New(server.URL, "sk", "test-model", "", 0, 0)
1948+
engine := New(client, tool.NewRegistry(nil), 10, "You are odek.", nil, 0)
1949+
engine.SetSkillLoader(skillLoader)
1950+
engine.SetEpisodeContextFunc(episodeCtx)
1951+
engine.SetUntrustedWrapper(func(source, content string) string {
1952+
return "WRAPPED:" + source + ":" + content
1953+
})
1954+
1955+
_, err := engine.Run(context.Background(), "test both wrappers")
1956+
if err != nil {
1957+
t.Fatalf("Run() error: %v", err)
1958+
}
1959+
1960+
if !sawSkillWrapped {
1961+
t.Error("skill context was not passed through the untrusted wrapper")
1962+
}
1963+
if !sawEpisodeWrapped {
1964+
t.Error("episode context was not passed through the untrusted wrapper")
1965+
}
1966+
}
1967+
19201968
func TestClassifyToolCall_Terminal(t *testing.T) {
19211969
risk, resource := classifyToolCall("terminal", `{"command":"whoami"}`)
19221970
if risk != danger.Safe {

0 commit comments

Comments
 (0)