Skip to content

Commit 1fd40d1

Browse files
authored
M7: preserve skill provenance through LLM enhancement and conversation extraction (#64)
1 parent 071dd36 commit 1fd40d1

4 files changed

Lines changed: 110 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
178178
- **MCP inputSchema hardening** (`cmd/odek/mcp_approval.go`) — every string in an MCP tool's `inputSchema` is recursively guard-scanned for injection patterns; schemas larger than 256 KiB are rejected; the interactive approval prompt shows a SHA-256 hash and byte size of the schema so operators can detect changes.
179179
- **MCP tool batch classification** (`internal/loop/loop.go`) — MCP tools (`<server>__<tool>`) are classified as `unknown` by `classifyToolCall`, so the batch approval gate shows them and untrusted sub-agents force them to `deny`.
180180
- **Sub-agent trust defaults + delegate_tasks gate** (`cmd/odek/subagent.go`, `internal/loop/loop.go`) — a missing `trust_level` in `delegate_tasks` defaults to `untrusted`; `delegate_tasks` itself is classified as `system_write` so it requires explicit approval before spawning child processes.
181+
- **Skill learn-loop provenance propagation** (`internal/skills/learnloop.go`) — conversation-extracted suggestions and LLM-enhanced suggestions both retain the session's `SkillProvenance`, so tainted sessions cannot produce clean-looking auto-saved skills.
181182
- **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>`, `POST /api/cancel`, 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.
182183
- **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.
183184
- **`env` / `printenv` environment-dump gate** (`internal/danger/classifier.go`) — bare `env` and `printenv` invocations are classified as `system_write` because they can leak process-environment secrets that the redaction scanner does not recognise. `env VAR=value <cmd>` still classifies `<cmd>` normally.

docs/SECURITY.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,15 @@ Now:
589589
- `cmd/odek/subagent.go::applySubagentTrust` treats a missing `trust_level` as `untrusted`, forcing `non_interactive: deny` and denying Destructive/CodeExecution/Install/SystemWrite/NetworkEgress/Unknown/Blocked.
590590
- `internal/loop/loop.go::classifyToolCall` classifies `delegate_tasks` as `system_write`, so spawning sub-agents requires explicit operator approval and cannot be used to escape the parent's approval gate.
591591

592+
### 39h. Skill learn-loop provenance propagation
593+
594+
Pattern-detected suggestions in `internal/skills/learnloop.go` were tagged with `DeriveProvenance`, but conversation-extracted suggestions (`ExtractSkillsFromConversation`) were appended without provenance, and the LLM enhancement step (`GenerateSkillWithLLM`) replaced the whole `SkillSuggestion` with the LLM-generated version, dropping the provenance. A tainted session could therefore produce a clean-looking auto-saved skill.
595+
596+
Now:
597+
598+
- Conversation-extracted suggestions receive the session provenance before being added to the suggestion list.
599+
- The enhancement loop copies the original suggestion's `Provenance` onto the LLM-enhanced result, so `IsTainted()` remains true and `AutoSaveSuggestions` declines the skill unless `allowUntrusted` is set.
600+
592601
### 40. `/api/resources` result limit cap
593602

594603
The `/api/resources?q=...&limit=N` autocomplete endpoint previously accepted any positive `limit` value. It is now capped to 100 results both in the HTTP handler and in `Registry.Search`. This prevents a prompt-injected or attacker-forged request from forcing an unbounded directory walk and returning a multi-megabyte JSON response.

internal/skills/learnloop.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,19 @@ func AnalyzeMessages(messages []LlmMessage, userMessages []string, sm *SkillMana
4141
cmds = append(cmds, c.Input)
4242
}
4343
convSkill.CommandLog = cmds
44+
convSkill.Provenance = prov
4445
suggestions = append(suggestions, *convSkill)
4546
}
4647
}
4748

48-
// Apply LLM enhancement to each suggestion.
49+
// Apply LLM enhancement to each suggestion. The LLM only produces name,
50+
// description, and body — it must not be allowed to strip the provenance
51+
// that was derived from the session's tool calls.
4952
if llmLearn && llmClient != nil {
5053
calls := ExtractToolCalls(messages)
5154
for i := range suggestions {
5255
if enhanced := GenerateSkillWithLLM(llmClient, calls, userMessages, suggestions[i].Heuristic); enhanced != nil {
56+
enhanced.Provenance = suggestions[i].Provenance
5357
enhanced.CommandLog = suggestions[i].CommandLog
5458
enhanced.Heuristic = suggestions[i].Heuristic
5559
suggestions[i] = *enhanced

internal/skills/learnloop_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package skills
22

33
import (
44
"bytes"
5+
"fmt"
56
"strings"
67
"testing"
78

@@ -132,3 +133,97 @@ func TestRunAutoSaveLoop_VerboseWriterReceivesFailedMessage(t *testing.T) {
132133
t.Errorf("verbose output should mention Quality gate failed for empty-body suggestion, got:\n%s", buf.String())
133134
}
134135
}
136+
137+
// validLLMSkillResponse returns the exact format GenerateSkillWithLLM and
138+
// ExtractSkillsFromConversation expect, with a body long enough to pass the
139+
// quality gate.
140+
func validLLMSkillResponse(name string) string {
141+
return fmt.Sprintf(`NAME: %s
142+
DESCRIPTION: A test skill
143+
TOPICS: test, ci
144+
ACTIONS: create, run
145+
BODY:
146+
## Overview
147+
This is a test skill body with enough padding to exceed the two hundred character minimum required by the quality gate. Keep typing filler words until we are safely over the limit for sure.
148+
149+
## Step-by-Step
150+
1. Do the thing
151+
152+
## Common Pitfalls
153+
- Nothing
154+
155+
## Verification
156+
- Check output
157+
`, name)
158+
}
159+
160+
// TestAnalyzeMessages_PreservesProvenanceThroughEnhancement verifies that
161+
// LLM-enhanced suggestions keep the provenance derived from the session, so
162+
// tainted sessions cannot produce clean-looking auto-saved skills.
163+
func TestAnalyzeMessages_PreservesProvenanceThroughEnhancement(t *testing.T) {
164+
sm := NewSkillManager(t.TempDir(), t.TempDir())
165+
messages := []LlmMessage{
166+
{Role: "user", Content: "fetch a page"},
167+
{Role: "assistant", Content: "ok", ToolCalls: []LlmToolCall{
168+
{Function: struct {
169+
Name string
170+
Arguments string
171+
}{Name: "browser", Arguments: `{"action":"navigate","url":"https://example.com"}`}},
172+
}},
173+
{Role: "tool", Content: "page text"},
174+
}
175+
userMsgs := []string{"fetch a page"}
176+
177+
llm := &mockLLMClient{resp: validLLMSkillResponse("enhanced-skill")}
178+
got := AnalyzeMessages(messages, userMsgs, sm, llm, true, true)
179+
if len(got) == 0 {
180+
t.Fatal("expected at least one suggestion")
181+
}
182+
for _, s := range got {
183+
if !s.Provenance.Untrusted {
184+
t.Errorf("suggestion %q lost tainted provenance after LLM enhancement: %+v", s.Name, s.Provenance)
185+
}
186+
if len(s.Provenance.Sources) == 0 || s.Provenance.Sources[0] != "browser" {
187+
t.Errorf("suggestion %q lost provenance sources after LLM enhancement: %+v", s.Name, s.Provenance.Sources)
188+
}
189+
}
190+
}
191+
192+
// TestAnalyzeMessages_ConversationExtractedIsTagged verifies that skills
193+
// extracted from the full conversation receive the session provenance before
194+
// they enter the auto-save pipeline.
195+
func TestAnalyzeMessages_ConversationExtractedIsTagged(t *testing.T) {
196+
sm := NewSkillManager(t.TempDir(), t.TempDir())
197+
messages := []LlmMessage{
198+
{Role: "user", Content: "how do I do this"},
199+
{Role: "assistant", Content: "use curl", ToolCalls: []LlmToolCall{
200+
{Function: struct {
201+
Name string
202+
Arguments string
203+
}{Name: "http_batch", Arguments: `{"urls":["https://example.com"]}`}},
204+
}},
205+
{Role: "tool", Content: "ok"},
206+
}
207+
userMsgs := []string{"how do I do this"}
208+
209+
llm := &mockLLMClient{resp: validLLMSkillResponse("conversation-skill")}
210+
got := AnalyzeMessages(messages, userMsgs, sm, llm, true, true)
211+
if len(got) == 0 {
212+
t.Fatal("expected at least one suggestion")
213+
}
214+
found := false
215+
for _, s := range got {
216+
if s.Heuristic == "conversation-extracted" {
217+
found = true
218+
if !s.Provenance.Untrusted {
219+
t.Errorf("conversation-extracted skill %q should be tainted, got %+v", s.Name, s.Provenance)
220+
}
221+
}
222+
}
223+
if !found {
224+
t.Errorf("expected a conversation-extracted suggestion, got heuristics: ")
225+
for _, s := range got {
226+
t.Logf(" %s: %s", s.Name, s.Heuristic)
227+
}
228+
}
229+
}

0 commit comments

Comments
 (0)