Skip to content

Commit dca833e

Browse files
committed
feat(cmd/odek): audit, untrusted wrapper, dispatch, subagent trust, skill promote, UI refactor
Refactored main.go: extracted 5 subsystems into dedicated files, reducing main.go from ~2800 lines. New infrastructure: - audit.go + audit_test.go: per-turn prompt-injection audit log with divergence heuristic. odek audit <session-id> / odek audit --list. - untrusted.go + untrusted_test.go: <untrusted_content_NONCE> wrapper for all externally-sourced tool outputs (browser, read_file, shell, search_files, multi_grep, transcribe, MCP tools). Per-call nonce defeats wrapper-escape attacks. - dispatch.go + dispatch_test.go: command dispatch extracted from main.go. - subagent_key.go + subagent_key_test.go: FD-based API key handoff for sub-agents (key written to 0600 tempfile, unlinked, passed via cmd.ExtraFiles -- never appears in /proc/PID/environ). New sub-agent trust model (subagent.go): - applySubagentTrust: delegate_tasks carries trust_level + max_risk. Untrusted => NonInteractive=deny, Destructive/CodeExec/Install/ SystemWrite/NetworkEgress all forced to Deny. - max_risk => everything above cap forced to Deny. Skill promote (skill_promote.go): - odek skill promote <name>: clear NeedsReview on a tainted skill after user review, unlocking it from Lazy-only loading. Serve hardening (serve.go): - Sandbox default-on for odek serve (--no-sandbox to opt out). - checkLocalOrigin: rejects non-localhost WebSocket upgrades (closes CSRF-on-localhost vector). - UI split: index.html reduced from ~2800 lines to ~200, JS extracted to ui/app.js, CSS to ui/style.css. WS approver friction (wsapprover.go): - WSApprover friction mode: after 3 same-class approvals in 60s, requires typing approve (full word) with a deliberate pause. - Trust-class shortcut disabled for destructive + blocked classes. Regression tests: - security_report_validation_test.go: validates every documented security mitigation has a corresponding test. - serve_origin_test.go, subagent_trust_test.go, untrusted_wrapper_test.go, batch_patch_audit_test.go. Test updates: all test files updated for new llm.New() signature (thinkingBudget param).
1 parent ce4afa4 commit dca833e

52 files changed

Lines changed: 7601 additions & 3732 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/odek/audit.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"github.com/BackendStack21/odek/internal/llm"
10+
"github.com/BackendStack21/odek/internal/session"
11+
)
12+
13+
// recordTurnAudit summarises a single agent turn into the audit log:
14+
// which tools were called, which resources they touched, whether any
15+
// untrusted content was ingested, and whether the resources referenced
16+
// by tool calls diverge from those mentioned in the user message.
17+
//
18+
// "Divergence" is a heuristic: a turn is flagged as suspicious when
19+
// the agent ingested untrusted content AND the tools called referenced
20+
// resources (URLs, paths, dotted names) that the user did not mention.
21+
// This is exactly the footprint of a successful prompt injection that
22+
// steered the agent toward an attacker-chosen resource.
23+
func recordTurnAudit(store *session.AuditStore, sessionID string, turn int, userText string, newMsgs []llm.Message) {
24+
if store == nil {
25+
return
26+
}
27+
28+
var toolCalls []string
29+
var toolText strings.Builder
30+
ingestedUntrusted := false
31+
32+
for _, m := range newMsgs {
33+
for _, tc := range m.ToolCalls {
34+
toolCalls = append(toolCalls, tc.Function.Name)
35+
toolText.WriteString(tc.Function.Arguments)
36+
toolText.WriteByte(' ')
37+
}
38+
if m.Role == "tool" {
39+
toolText.WriteString(m.Content)
40+
toolText.WriteByte(' ')
41+
if hasUntrustedWrapper(m.Content) {
42+
ingestedUntrusted = true
43+
}
44+
}
45+
}
46+
47+
novel := session.NovelResources(userText, toolText.String())
48+
49+
// We do not flag divergence on untainted turns — a trusted internal
50+
// search legitimately surfaces resources the user did not name.
51+
suspicious := ingestedUntrusted && len(novel) > 0
52+
53+
at := session.AuditTurn{
54+
Turn: turn,
55+
UserMessage: userText,
56+
ToolCalls: toolCalls,
57+
NovelResources: novel,
58+
IngestedUntrusted: ingestedUntrusted,
59+
SuspiciousDivergence: suspicious,
60+
}
61+
_ = store.RecordTurn(sessionID, at)
62+
}
63+
64+
// auditCmd handles `odek audit <session-id>` and `odek audit --list`.
65+
// Read-only: it never modifies the audit log. Output is JSON to stdout
66+
// so the caller can pipe through jq / their tool of choice.
67+
func auditCmd(args []string) error {
68+
if len(args) == 0 {
69+
printAuditUsage()
70+
return fmt.Errorf("audit: argument required")
71+
}
72+
store, err := session.NewStore()
73+
if err != nil {
74+
return fmt.Errorf("audit: session store: %w", err)
75+
}
76+
auditStore := session.NewAuditStore(store.Dir())
77+
78+
switch args[0] {
79+
case "--help", "-h", "help":
80+
printAuditUsage()
81+
return nil
82+
case "--list":
83+
return auditList(store, auditStore)
84+
default:
85+
log, err := auditStore.Load(args[0])
86+
if err != nil {
87+
return fmt.Errorf("audit: load: %w", err)
88+
}
89+
out, err := json.MarshalIndent(log, "", " ")
90+
if err != nil {
91+
return fmt.Errorf("audit: marshal: %w", err)
92+
}
93+
fmt.Println(string(out))
94+
return nil
95+
}
96+
}
97+
98+
func auditList(store *session.Store, auditStore *session.AuditStore) error {
99+
sessions, err := store.List(0)
100+
if err != nil {
101+
return fmt.Errorf("audit: list sessions: %w", err)
102+
}
103+
fmt.Fprintf(os.Stderr, "Session Ingests Turns Suspicious First-Ingest-Source\n")
104+
for _, s := range sessions {
105+
log, err := auditStore.Load(s.ID)
106+
if err != nil || len(log.Ingests) == 0 {
107+
continue
108+
}
109+
suspicious := 0
110+
for _, t := range log.Turns {
111+
if t.SuspiciousDivergence {
112+
suspicious++
113+
}
114+
}
115+
firstSource := log.Ingests[0].Source
116+
if len(firstSource) > 40 {
117+
firstSource = firstSource[:37] + "..."
118+
}
119+
fmt.Printf("%-22s %7d %6d %11d %s\n",
120+
s.ID, len(log.Ingests), len(log.Turns), suspicious, firstSource)
121+
}
122+
return nil
123+
}
124+
125+
func printAuditUsage() {
126+
fmt.Println(`Usage: odek audit <session-id>
127+
odek audit --list
128+
129+
Prints the prompt-injection audit log for a session.
130+
131+
The log records every time the agent ingested externally-sourced
132+
content (a fetched page, a file outside the working directory, an MCP
133+
tool response, audio transcript, etc.) along with a per-turn
134+
divergence assessment — turns where the agent referenced resources
135+
the user did not mention AND the session ingested untrusted content
136+
are flagged as 'suspicious'.
137+
138+
Output is JSON to stdout.`)
139+
}

cmd/odek/audit_cmd_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package main
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/BackendStack21/odek/internal/session"
8+
)
9+
10+
// captureStderrDuring uses the existing captureStderr helper (from
11+
// sandbox_test.go, which returns a flush closure) and runs fn inside.
12+
func captureStderrDuring(t *testing.T, fn func()) string {
13+
t.Helper()
14+
flush := captureStderr(t)
15+
fn()
16+
return flush()
17+
}
18+
19+
// withTempHome redirects HOME to a fresh tempdir so session.NewStore
20+
// writes under a sandbox path.
21+
func withTempHome(t *testing.T) string {
22+
t.Helper()
23+
dir := t.TempDir()
24+
t.Setenv("HOME", dir)
25+
return dir
26+
}
27+
28+
func TestPrintAuditUsage_OutputsKeyTokens(t *testing.T) {
29+
out := captureStdout(printAuditUsage)
30+
for _, want := range []string{
31+
"odek audit",
32+
"<session-id>",
33+
"--list",
34+
"audit log",
35+
"suspicious",
36+
"JSON",
37+
} {
38+
if !strings.Contains(out, want) {
39+
t.Errorf("usage missing %q\noutput:\n%s", want, out)
40+
}
41+
}
42+
}
43+
44+
func TestAuditCmd_NoArgs_PrintsUsageAndErrors(t *testing.T) {
45+
withTempHome(t)
46+
out := captureStdout(func() {
47+
err := auditCmd(nil)
48+
if err == nil {
49+
t.Error("expected error when called with no args")
50+
} else if !strings.Contains(err.Error(), "argument required") {
51+
t.Errorf("error = %v, want 'argument required'", err)
52+
}
53+
})
54+
if !strings.Contains(out, "odek audit") {
55+
t.Errorf("usage should have been printed, got:\n%s", out)
56+
}
57+
}
58+
59+
func TestAuditCmd_Help_PrintsUsage(t *testing.T) {
60+
withTempHome(t)
61+
for _, flag := range []string{"--help", "-h", "help"} {
62+
t.Run(flag, func(t *testing.T) {
63+
out := captureStdout(func() {
64+
if err := auditCmd([]string{flag}); err != nil {
65+
t.Fatalf("auditCmd(%q): %v", flag, err)
66+
}
67+
})
68+
if !strings.Contains(out, "odek audit") {
69+
t.Errorf("usage missing from %q output:\n%s", flag, out)
70+
}
71+
})
72+
}
73+
}
74+
75+
func TestAuditCmd_LoadByID_NoSuchSession(t *testing.T) {
76+
withTempHome(t)
77+
// AuditStore.Load returns empty AuditLog when the file is missing,
78+
// not an error — so auditCmd should succeed and print an empty log.
79+
out := captureStdout(func() {
80+
if err := auditCmd([]string{"20260529-deadbe"}); err != nil {
81+
t.Fatalf("auditCmd: %v", err)
82+
}
83+
})
84+
// Empty log marshals with a session_id field set but ingests/turns null.
85+
if !strings.Contains(out, "\"session_id\"") {
86+
t.Errorf("expected JSON with session_id key, got:\n%s", out)
87+
}
88+
if !strings.Contains(out, "20260529-deadbe") {
89+
t.Errorf("expected the session ID echoed in the JSON, got:\n%s", out)
90+
}
91+
}
92+
93+
func TestAuditCmd_LoadByID_InvalidID(t *testing.T) {
94+
withTempHome(t)
95+
err := auditCmd([]string{"../etc/passwd"})
96+
if err == nil {
97+
t.Fatal("expected error for path-traversal ID")
98+
}
99+
if !strings.Contains(err.Error(), "audit:") {
100+
t.Errorf("error should be wrapped with 'audit:', got: %v", err)
101+
}
102+
}
103+
104+
func TestAuditCmd_List_EmptyStore(t *testing.T) {
105+
withTempHome(t)
106+
// No sessions yet → header on stderr, no rows on stdout, no error.
107+
stderr := captureStderrDuring(t, func() {
108+
if err := auditCmd([]string{"--list"}); err != nil {
109+
t.Fatalf("auditCmd --list: %v", err)
110+
}
111+
})
112+
if !strings.Contains(stderr, "Session") || !strings.Contains(stderr, "Ingests") {
113+
t.Errorf("expected header on stderr, got:\n%s", stderr)
114+
}
115+
}
116+
117+
func TestAuditCmd_LoadByID_RoundtripWithRecorded(t *testing.T) {
118+
withTempHome(t)
119+
120+
// Stand up a real session + audit log so auditCmd has something to load.
121+
store, err := session.NewStore()
122+
if err != nil {
123+
t.Fatalf("NewStore: %v", err)
124+
}
125+
auditStore := session.NewAuditStore(store.Dir())
126+
127+
const sid = "20260529-abc001"
128+
if err := auditStore.RecordIngest(sid, 1, "https://example.com", "hello"); err != nil {
129+
t.Fatalf("RecordIngest: %v", err)
130+
}
131+
if err := auditStore.RecordTurn(sid, session.AuditTurn{
132+
Turn: 1,
133+
UserMessage: "do thing",
134+
ToolCalls: []string{"shell"},
135+
IngestedUntrusted: true,
136+
SuspiciousDivergence: false,
137+
}); err != nil {
138+
t.Fatalf("RecordTurn: %v", err)
139+
}
140+
141+
out := captureStdout(func() {
142+
if err := auditCmd([]string{sid}); err != nil {
143+
t.Fatalf("auditCmd: %v", err)
144+
}
145+
})
146+
for _, want := range []string{
147+
sid,
148+
"https://example.com",
149+
"\"ingested_untrusted\": true",
150+
"\"tool_calls\"",
151+
} {
152+
if !strings.Contains(out, want) {
153+
t.Errorf("audit dump missing %q\noutput:\n%s", want, out)
154+
}
155+
}
156+
}
157+
158+
func TestAuditList_PopulatedSession(t *testing.T) {
159+
withTempHome(t)
160+
161+
store, err := session.NewStore()
162+
if err != nil {
163+
t.Fatalf("NewStore: %v", err)
164+
}
165+
// Save a real session so store.List returns something.
166+
sess := session.Session{
167+
ID: "20260529-listme",
168+
Task: "test",
169+
Turns: 1,
170+
}
171+
if err := store.Save(&sess); err != nil {
172+
t.Fatalf("Save session: %v", err)
173+
}
174+
175+
auditStore := session.NewAuditStore(store.Dir())
176+
// Long source string to exercise the truncation branch.
177+
longSource := strings.Repeat("a", 60)
178+
if err := auditStore.RecordIngest(sess.ID, 1, longSource, "data"); err != nil {
179+
t.Fatalf("RecordIngest: %v", err)
180+
}
181+
if err := auditStore.RecordTurn(sess.ID, session.AuditTurn{
182+
Turn: 1,
183+
IngestedUntrusted: true,
184+
SuspiciousDivergence: true,
185+
}); err != nil {
186+
t.Fatalf("RecordTurn: %v", err)
187+
}
188+
189+
// auditList writes the header to stderr and the rows to stdout, so
190+
// capture both. The order is: open stderr capture, then run the
191+
// stdout capture (which executes the function).
192+
flushStderr := captureStderr(t)
193+
stdout := captureStdout(func() {
194+
if err := auditList(store, auditStore); err != nil {
195+
t.Fatalf("auditList: %v", err)
196+
}
197+
})
198+
stderr := flushStderr()
199+
combined := stderr + stdout
200+
201+
if !strings.Contains(combined, sess.ID) {
202+
t.Errorf("auditList should list session %q\nstderr:\n%s\nstdout:\n%s", sess.ID, stderr, stdout)
203+
}
204+
if !strings.Contains(combined, "...") {
205+
t.Errorf("long source should have been truncated with '...'\nstderr:\n%s\nstdout:\n%s", stderr, stdout)
206+
}
207+
}

0 commit comments

Comments
 (0)