Skip to content

Commit c8b9e3e

Browse files
committed
feat: SOUL.md identity file + prompt injection scanner
SOUL.md (~/.odek/IDENTITY.md): - Swappable agent identity without recompilation - Priority: explicit --system > IDENTITY.md > compiled defaultSystem - Centralized buildSystemPrompt() replaces 6 duplicate call sites - 6 tests: file exists, no file, empty file, explicit wins, identity fallback, default fallback Prompt injection scanner (internal/danger/injection.go): - 17 regex patterns ported from Hermes Agent - Detects: identity override, hidden unicode, exfiltration, base64, HTML injections, social engineering, markdown header injections - ScanInjection() returns labeled threats, IsSafe() for simple gate - Wired into AGENTS.md loading — injected files are rejected - 11 tests: 10 positive categories + clean + empty + multi-threat
1 parent a67548b commit c8b9e3e

9 files changed

Lines changed: 412 additions & 66 deletions

File tree

cmd/odek/main.go

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,46 @@ Safety:
129129
or any destructive command (rm, shred, mv, etc.). These files may contain secrets.
130130
To extract specific config values, use grep or jq to pull only the fields you need.`
131131

132+
// buildSystemPrompt assembles the system prompt by priority:
133+
// 1. resolved.System (explicit --system / ODEK_SYSTEM / config)
134+
// 2. ~/.odek/IDENTITY.md (swappable identity file)
135+
// 3. defaultSystem (compiled-in fallback)
136+
//
137+
// After selecting the base, it appends repo directory/URL context.
138+
func buildSystemPrompt(resolved config.ResolvedConfig) string {
139+
base := resolved.System
140+
if base == "" {
141+
base = loadIdentityFile()
142+
}
143+
144+
if resolved.GithubRepoDirectory != "" {
145+
base += fmt.Sprintf("\n\nRepository directory: %s\nThis is the local clone of the project repository.", resolved.GithubRepoDirectory)
146+
}
147+
if resolved.GithubRepoUrl != "" {
148+
base += fmt.Sprintf("\nRepository URL: %s\nThis is the upstream GitHub repository.", resolved.GithubRepoUrl)
149+
}
150+
return base
151+
}
152+
153+
// loadIdentityFile reads ~/.odek/IDENTITY.md and returns its content.
154+
// Returns defaultSystem if the file does not exist or cannot be read.
155+
func loadIdentityFile() string {
156+
home, err := os.UserHomeDir()
157+
if err != nil {
158+
return defaultSystem
159+
}
160+
path := filepath.Join(home, ".odek", "IDENTITY.md")
161+
data, err := os.ReadFile(path)
162+
if err != nil {
163+
return defaultSystem
164+
}
165+
content := strings.TrimSpace(string(data))
166+
if content == "" {
167+
return defaultSystem
168+
}
169+
return content
170+
}
171+
132172
// dockerfileName is the filename for project-specific Docker images.
133173
// When this file exists in the working directory and no explicit
134174
// sandbox_image is configured, odek builds a content-hash-cached
@@ -816,17 +856,8 @@ func run(args []string) error {
816856
}
817857
f.Task = enriched
818858

819-
// Determine system message: CLI/project/env override, or default
820-
systemMessage := resolved.System
821-
if systemMessage == "" {
822-
systemMessage = defaultSystem
823-
}
824-
if resolved.GithubRepoDirectory != "" {
825-
systemMessage += fmt.Sprintf("\n\nRepository directory: %s\nThis is the local clone of the project repository. You can read and modify files here.", resolved.GithubRepoDirectory)
826-
}
827-
if resolved.GithubRepoUrl != "" {
828-
systemMessage += fmt.Sprintf("\nRepository URL: %s\nThis is the upstream GitHub repository.", resolved.GithubRepoUrl)
829-
}
859+
// Build system prompt: explicit override > IDENTITY.md > compiled default
860+
systemMessage := buildSystemPrompt(resolved)
830861

831862
// Build sandbox config from resolved settings (first occurrence)
832863
sbCfg := sandboxConfig{
@@ -1770,16 +1801,7 @@ func continueCmd(args []string) error {
17701801
defer mcpCleanup()
17711802
}
17721803

1773-
systemMessage := resolved.System
1774-
if systemMessage == "" {
1775-
systemMessage = defaultSystem
1776-
}
1777-
if resolved.GithubRepoDirectory != "" {
1778-
systemMessage += fmt.Sprintf("\n\nRepository directory: %s\nThis is the local clone of the project repository.", resolved.GithubRepoDirectory)
1779-
}
1780-
if resolved.GithubRepoUrl != "" {
1781-
systemMessage += fmt.Sprintf("\nRepository URL: %s\nThis is the upstream GitHub repository.", resolved.GithubRepoUrl)
1782-
}
1804+
systemMessage := buildSystemPrompt(resolved)
17831805

17841806
// Sandbox (if enabled in config) (second occurrence)
17851807
if resolved.Sandbox {

cmd/odek/main_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1945,3 +1945,100 @@ func TestBoolPtr_False(t *testing.T) {
19451945
t.Errorf("boolPtr(false) = %v, want false", *p)
19461946
}
19471947
}
1948+
1949+
// ── IDENTITY.md Tests ─────────────────────────────────────────────
1950+
1951+
// setupTestHome creates a temp home directory, sets HOME to it, and
1952+
// returns a cleanup function that restores the original value.
1953+
func setupTestHome(t *testing.T) string {
1954+
t.Helper()
1955+
orig := os.Getenv("HOME")
1956+
dir := t.TempDir()
1957+
os.Setenv("HOME", dir)
1958+
t.Cleanup(func() { os.Setenv("HOME", orig) })
1959+
return dir
1960+
}
1961+
1962+
func TestLoadIdentityFile_FileExists(t *testing.T) {
1963+
homeDir := setupTestHome(t)
1964+
os.MkdirAll(filepath.Join(homeDir, ".odek"), 0700)
1965+
content := "# Custom Identity\n\nI am a test agent."
1966+
os.WriteFile(filepath.Join(homeDir, ".odek", "IDENTITY.md"), []byte(content), 0644)
1967+
1968+
got := loadIdentityFile()
1969+
if got != content {
1970+
t.Errorf("loadIdentityFile() = %q, want %q", got, content)
1971+
}
1972+
}
1973+
1974+
func TestLoadIdentityFile_NoFile(t *testing.T) {
1975+
_ = setupTestHome(t)
1976+
got := loadIdentityFile()
1977+
if got != defaultSystem {
1978+
t.Errorf("expected defaultSystem when no IDENTITY.md, got %q", got)
1979+
}
1980+
}
1981+
1982+
func TestLoadIdentityFile_EmptyFile(t *testing.T) {
1983+
homeDir := setupTestHome(t)
1984+
os.MkdirAll(filepath.Join(homeDir, ".odek"), 0700)
1985+
os.WriteFile(filepath.Join(homeDir, ".odek", "IDENTITY.md"), []byte(" \n\n "), 0644)
1986+
1987+
got := loadIdentityFile()
1988+
if got != defaultSystem {
1989+
t.Errorf("expected defaultSystem for empty IDENTITY.md, got %q", got)
1990+
}
1991+
}
1992+
1993+
func TestBuildSystemPrompt_ExplicitSystemWins(t *testing.T) {
1994+
homeDir := setupTestHome(t)
1995+
os.MkdirAll(filepath.Join(homeDir, ".odek"), 0700)
1996+
os.WriteFile(filepath.Join(homeDir, ".odek", "IDENTITY.md"), []byte("# Identity from file"), 0644)
1997+
1998+
resolved := config.ResolvedConfig{
1999+
System: "Explicit system override",
2000+
GithubRepoDirectory: "/tmp/repo",
2001+
}
2002+
2003+
got := buildSystemPrompt(resolved)
2004+
if !strings.Contains(got, "Explicit system override") {
2005+
t.Errorf("expected explicit system override, got %q", got)
2006+
}
2007+
if strings.Contains(got, "Identity from file") {
2008+
t.Error("IDENTITY.md content should NOT appear when explicit System is set")
2009+
}
2010+
if !strings.Contains(got, "/tmp/repo") {
2011+
t.Error("repo directory should appear in prompt")
2012+
}
2013+
}
2014+
2015+
func TestBuildSystemPrompt_FallsBackToIdentity(t *testing.T) {
2016+
homeDir := setupTestHome(t)
2017+
os.MkdirAll(filepath.Join(homeDir, ".odek"), 0700)
2018+
os.WriteFile(filepath.Join(homeDir, ".odek", "IDENTITY.md"), []byte("# Custom Identity"), 0644)
2019+
2020+
resolved := config.ResolvedConfig{}
2021+
2022+
got := buildSystemPrompt(resolved)
2023+
if !strings.Contains(got, "# Custom Identity") {
2024+
t.Errorf("expected IDENTITY.md content, got %q", got)
2025+
}
2026+
if strings.Contains(got, "⚠️ ANTI-PATTERN") {
2027+
t.Error("defaultSystem should NOT appear when IDENTITY.md exists")
2028+
}
2029+
}
2030+
2031+
func TestBuildSystemPrompt_FallsBackToDefault(t *testing.T) {
2032+
_ = setupTestHome(t)
2033+
resolved := config.ResolvedConfig{
2034+
GithubRepoUrl: "https://github.com/test/repo",
2035+
}
2036+
2037+
got := buildSystemPrompt(resolved)
2038+
if !strings.Contains(got, "⚠️ ANTI-PATTERN") {
2039+
t.Error("expected defaultSystem prefix when no override or IDENTITY.md")
2040+
}
2041+
if !strings.Contains(got, "https://github.com/test/repo") {
2042+
t.Error("repo URL should appear in prompt")
2043+
}
2044+
}

cmd/odek/mcp.go

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,6 @@ Flags:
5151
// Load config
5252
resolved := config.LoadConfig(cliFlags)
5353

54-
// Resolve system message
55-
systemMessage := resolved.System
56-
if systemMessage == "" {
57-
systemMessage = defaultSystem
58-
}
59-
if resolved.GithubRepoDirectory != "" {
60-
systemMessage += fmt.Sprintf("\n\nRepository directory: %s\nThis is the local clone of the project repository. You can read and modify files here.", resolved.GithubRepoDirectory)
61-
}
62-
if resolved.GithubRepoUrl != "" {
63-
systemMessage += fmt.Sprintf("\nRepository URL: %s\nThis is the upstream GitHub repository.", resolved.GithubRepoUrl)
64-
}
65-
6654
// Start agent loop (mcp)
6755
sbCfg := sandboxConfig{
6856
Image: resolved.SandboxImage,

cmd/odek/repl.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,9 @@ func replCmd(args []string) error {
5959
SandboxCPUs: f.SandboxCPUs,
6060
SandboxUser: f.SandboxUser,
6161
})
62-
systemMessage := resolved.System
63-
if systemMessage == "" {
64-
systemMessage = defaultSystem
65-
}
62+
systemMessage := buildSystemPrompt(resolved)
6663
if resolved.GithubRepoDirectory != "" {
67-
systemMessage += fmt.Sprintf("\n\nRepository directory: %s\nThis is the local clone of the project repository. You can read and modify files here. When asked to update your own code, this is where the source lives.", resolved.GithubRepoDirectory)
68-
}
69-
if resolved.GithubRepoUrl != "" {
70-
systemMessage += fmt.Sprintf("\nRepository URL: %s\nThis is the upstream GitHub repository.", resolved.GithubRepoUrl)
64+
systemMessage += " You can read and modify files here. When asked to update your own code, this is where the source lives."
7165
}
7266

7367
// session resume

cmd/odek/serve.go

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,7 @@ func serveCmd(args []string) error {
9696
SandboxCPUs: sandboxCPUs,
9797
SandboxUser: sandboxUser,
9898
})
99-
systemMessage := resolved.System
100-
if systemMessage == "" {
101-
systemMessage = defaultSystem
102-
}
103-
if resolved.GithubRepoDirectory != "" {
104-
systemMessage += fmt.Sprintf("\n\nRepository directory: %s\nThis is the local clone of the project repository.", resolved.GithubRepoDirectory)
105-
}
106-
if resolved.GithubRepoUrl != "" {
107-
systemMessage += fmt.Sprintf("\nRepository URL: %s\nThis is the upstream GitHub repository.", resolved.GithubRepoUrl)
108-
}
99+
systemMessage := buildSystemPrompt(resolved)
109100

110101
// Build sandbox config from resolved settings (serve)
111102
store, err := session.NewStore()

cmd/odek/telegram.go

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -118,20 +118,10 @@ func telegramCmd(args []string) error {
118118
AllowedUsers: cfg.AllowedUsers,
119119
}
120120

121-
// 9. Resolve system message.
122-
systemMessage := resolved.System
123-
if systemMessage == "" {
124-
systemMessage = defaultSystem
125-
}
126-
if resolved.GithubRepoDirectory != "" {
127-
systemMessage += fmt.Sprintf("\n\nRepository directory: %s\nThis is the local clone of the project repository.", resolved.GithubRepoDirectory)
128-
}
129-
if resolved.GithubRepoUrl != "" {
130-
systemMessage += fmt.Sprintf("\nRepository URL: %s\nThis is the upstream GitHub repository.", resolved.GithubRepoUrl)
131-
}
121+
// 9. Build system prompt: explicit override > IDENTITY.md > compiled default
122+
systemMessage := buildSystemPrompt(resolved)
132123

133-
// Quick Facts: must-remember odek metadata injected at the end of the
134-
// system prompt so the model sees them right before the user message.
124+
// Telegram-specific Quick Facts and recovery guidance
135125
systemMessage += "\n\nQuick Facts (use these, do NOT search):\n"
136126
systemMessage += "- odek website: https://kode.21no.de\n"
137127
systemMessage += "- Built by: 21no.de (https://21no.de)\n"

internal/danger/injection.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package danger
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
)
7+
8+
// InjectionPattern groups a compiled regex with a human-readable label
9+
// describing what threat it detects.
10+
type InjectionPattern struct {
11+
Re *regexp.Regexp
12+
Label string
13+
}
14+
15+
// injectionPatterns is the canonical set of prompt injection detection
16+
// patterns, ported from Hermes Agent's _scan_context_content().
17+
// Patterns cover: identity override, hidden unicode, exfiltration,
18+
// encoded instructions, HTML comment injections, and social engineering.
19+
var injectionPatterns = []InjectionPattern{
20+
// ── Identity override ──────────────────────────────────────────
21+
{regexp.MustCompile(`(?i)ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directives?|rules?|messages?)`), "ignore previous instructions"},
22+
{regexp.MustCompile(`(?i)disregard\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directives?|rules?)`), "disregard instructions"},
23+
{regexp.MustCompile(`(?i)you\s+(are\s+)?(now|no\s+longer)\s+.*?\b(AI|assistant|agent|model)\b`), "identity replacement"},
24+
{regexp.MustCompile(`(?i)(new|updated|revised)\s+system\s+(prompt|instructions?|message)`), "new system prompt"},
25+
{regexp.MustCompile(`(?i)(your|the)\s+(new|primary|overriding)\s+(directive|goal|purpose)\s+(is|shall\s+be)`), "overriding directive"},
26+
27+
// ── Hidden unicode ─────────────────────────────────────────────
28+
{regexp.MustCompile(`[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}-\x{2064}\x{FEFF}]`), "hidden unicode characters"},
29+
30+
// ── Exfiltration attempts ──────────────────────────────────────
31+
{regexp.MustCompile(`(?i)(print|output|display|show|echo|reveal|dump|export|write)\s+(your|the)\s+(system\s+(prompt|message|instructions?)|instructions?|directives?|rules?|initial\s+(message|instructions?))`), "system prompt exfiltration"},
32+
{regexp.MustCompile(`(?i)(send|post|upload|transmit)\s+(your|the)\s+(system\s+prompt|instructions?)`), "transmit system prompt"},
33+
{regexp.MustCompile(`(?i)(what|tell\s+me)\s+(is\s+)?(your|the)\s+(system\s+prompt|initial\s+instructions?)`), "prompt interrogation"},
34+
35+
// ── Encoded / obfuscated instructions ──────────────────────────
36+
{regexp.MustCompile(`(?i)base64\s*(decode|encoded)\s*:?\s*[A-Za-z0-9+/=]{20,}`), "base64-encoded payload"},
37+
{regexp.MustCompile(`(?i)(decode|interpret|execute)\s+(this|the\s+following)\s+(base64|hex|encoded)`), "encoded instruction"},
38+
39+
// ── HTML / markup injections ───────────────────────────────────
40+
{regexp.MustCompile(`<!--.{0,50}(ignore|disregard|new\s+instructions|system\s+prompt).{0,50}-->`), "HTML comment injection"},
41+
{regexp.MustCompile(`(?i)<script|<style|<iframe|<object|<embed`), "HTML tag injection"},
42+
43+
// ── Social engineering / confusion ─────────────────────────────
44+
{regexp.MustCompile(`(?i)(you\s+(have\s+been|are\s+being)\s+(hacked|compromised|tricked))`), "gaslighting"},
45+
{regexp.MustCompile(`(?i)(the\s+user\s+(said|wants|told\s+you))`), "user impersonation"},
46+
{regexp.MustCompile(`(?i)(from\s+now\s+on|henceforth|starting\s+now)\s*,?\s*(you\s+(are|will|must|shall))`), "permanent override"},
47+
{regexp.MustCompile(`(?i)^\s*#+\s*(new|updated|revised|corrected)\s+(system\s+prompt|instructions?)`), "markdown header injection"},
48+
}
49+
50+
// ScanResult describes a single detected injection threat.
51+
type ScanResult struct {
52+
Label string // human-readable threat label
53+
Pattern string // the regexp pattern that matched (for debugging)
54+
}
55+
56+
// ScanInjection checks content for prompt injection attempts.
57+
// Returns nil if no threats detected, or a list of found threats.
58+
// Each threat includes a label describing what was found.
59+
func ScanInjection(content string) []ScanResult {
60+
if content == "" {
61+
return nil
62+
}
63+
64+
// Normalize for scanning: lowercase, strip excess whitespace
65+
normalized := strings.ToLower(strings.TrimSpace(content))
66+
67+
var results []ScanResult
68+
for _, p := range injectionPatterns {
69+
if p.Re.MatchString(normalized) {
70+
results = append(results, ScanResult{
71+
Label: p.Label,
72+
Pattern: p.Re.String(),
73+
})
74+
}
75+
}
76+
return results
77+
}
78+
79+
// IsSafe returns true if no injection threats are detected in content.
80+
// This is the primary gate used before injecting untrusted content into
81+
// the system prompt.
82+
func IsSafe(content string) bool {
83+
return len(ScanInjection(content)) == 0
84+
}

0 commit comments

Comments
 (0)