Skip to content

Commit 4ffdb7d

Browse files
jkyberneeesclaude
andauthored
feat(memory): path-aware episode/skill provenance + human-gated promote (#10)
* feat(memory): path-aware episode/skill provenance + human-gated promote Fix the "episode dead-end": provenance tainted episodes by tool *name* only, so read_file/search_files/multi_grep — used in essentially every coding session — always produced an untrusted episode. Untrusted episodes are excluded from recall, and nothing ever set UserApproved, so for normal work the episode tier was written but never replayed (dead weight). Provenance is now argument-aware via a single shared predicate `memory.ToolCallTaints(name, argsJSON)`: - Always untrusted: browser, http_batch, transcribe, and any MCP tool (__). - Path-scoped (read_file, search_files, multi_grep): taint only when the `path` argument resolves to a sensitive location (danger.ClassifyPath → system_write/destructive, e.g. /etc, ~/.ssh, ~/.aws). Workspace and other non-sensitive local reads stay trusted. Malformed/absent path → conservative taint. This precisely re-targets the original "covers /etc/, $HOME/.ssh" concern while letting ordinary sessions stay recallable. The skills provenance (internal/skills/selfimprove.go) now delegates to the same predicate, so learned skills stop being over-flagged for review too. Reusing danger.ClassifyPath means DeriveProvenance keeps its (messages) signature — no cwd plumbing into the async session-end call sites. Escape hatch for genuinely-external episodes: new human-gated CLI `odek memory list` / `odek memory promote <session_id>` sets UserApproved. It is intentionally NOT an agent tool — a prompt-injected agent must not be able to self-approve its own poisoned memory. Also fixes two stale descriptions found while mapping the system: the extract_on_end docs ("durable facts" → episode summary) and the FormatEpisodeContext comment ("recency-based ranker, no LLM" → it uses the configured, LLM-by-default ranker). Tests: path-aware taint (workspace trusted, sensitive/system tainted, malformed conservative, always-external), the ToolCallTaints table, episode promote/pending lifecycle + persistence, skills mirror (guards the llm.Message→LlmMessage Arguments plumbing), and the CLI command end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(memory): close provenance bypasses found by verification (D-01/D-02/D-03/D-05) Remediates the findings from the AI Verification Protocol run on this PR. The v1 taint check used a sensitive-path *denylist* (danger.ClassifyPath) over only three tools, which left several bypasses: - D-03 (regression): macOS /etc and /var are symlinks to /private/etc, /private /var. ClassifyPath only matched /etc,/var, so read_file("/private/etc/...") classified local_write and did NOT taint — a sensitive read that tainted before this PR. Confirmed live. - D-01: sibling whole-file readers (batch_read, json_query, head_tail, sort, tr, diff, count_lines, checksum, word_count, file_info, glob, tree, base64) were in no taint set at all, so reading /etc/shadow via batch_read produced a TRUSTED, recallable episode. - D-02: home credential files outside ClassifyPath's hardcoded subdir list (e.g. ~/secrets.env) classified local_write and did not taint. - D-05: session_search re-surfaces prior-session transcripts but never tainted. Fix — flip the model from a sensitive-path denylist to a workspace-containment allowlist, and broaden coverage: - ToolCallTaints now taints a path-reading tool when ANY of its path arguments (path / path_a / path_b / files[].path) resolves OUTSIDE the workspace trust zone (cwd, the sandbox /workspace mount, or ~/.odek). Symlinks are resolved so /etc -> /private/etc can't disguise an escape. Workspace reads stay trusted (the original goal); everything else taints. Malformed args still taint conservatively; absent/empty path defaults to the workspace. - PathReadingTools expanded to every file-reading tool; session_search added to AlwaysExternalTools. Provenance no longer depends on ClassifyPath. - Defense in depth: ClassifyPath now strips the macOS /private prefix so /private/etc|var classify the same as /etc|var for its other callers (transcribe risk, write confinement). Also adds the missing manager-wrapper tests (PromoteEpisode / PendingReviewEpisodes were at 0% coverage — the report's coverage finding; now 100%) and updates docs/SECURITY.md to describe the containment model accurately (the report's doc-accuracy finding). Verified: batch_read/json_query/diff of /etc, read_file of /private/etc and ~/secrets.env, and session_search all taint; workspace reads (relative + abs) and the /workspace mount stay trusted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(memory): optional auto-approval of episode learnings (default off) Add `memory.auto_approve_episodes` (default false). When enabled, untrusted episodes are stamped approved at session end so they are recalled without a manual `odek memory promote` — an explicit opt-in that trades the human review gate for convenience in trusted/sandboxed deployments. - New EpisodeProvenance.AutoApproved field, kept DISTINCT from UserApproved so the audit trail shows the approval was automatic (policy), while Untrusted + Sources are still recorded. Episode Search and PendingReview treat AutoApproved as approved. - The stamp is applied at write time in OnSessionEndWithProvenance only when the flag is on and the episode is untrusted; trusted episodes are unaffected. - Config plumbed through MemoryConfig (default false), NewMemoryManager merge, and resolveMemory. - docs/CONFIG.md and docs/SECURITY.md document it as a security trade-off that disables the persistence-injection protection for episodes when on. Tests: store-level recall of an AutoApproved episode + exclusion from pending; the secure default is false; and the full OnSessionEnd stamping path with the flag on (AutoApproved set, recallable, not pending) vs off (excluded, pending). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d4d0bc5 commit 4ffdb7d

15 files changed

Lines changed: 821 additions & 63 deletions

cmd/odek/dispatch.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ func dispatch(args []string) int {
5353
return cliExit(telegramCmd(rest))
5454
case "schedule":
5555
return cliExit(scheduleCmd(rest))
56+
case "memory":
57+
return cliExit(memoryCmd(rest))
5658
default:
5759
fmt.Fprintf(os.Stderr, "odek: unknown command %q\n", cmd)
5860
printUsage()

cmd/odek/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ func printUsage() {
452452
odek mcp [--sandbox]
453453
odek telegram
454454
odek schedule <list|add|rm|enable|disable|run|next|daemon>
455+
odek memory <list|promote <session_id>>
455456
odek version
456457
457458
Commands:
@@ -477,6 +478,10 @@ Commands:
477478
Subcommands: list, add, rm, enable, disable, run, next, daemon
478479
The daemon (or the Telegram bot) fires jobs and delivers
479480
results to stdout, a log, or a Telegram chat.
481+
memory Review and promote past-session memory episodes
482+
list: show episodes excluded from recall (untrusted)
483+
promote <session_id>: approve one so it can be recalled.
484+
Human-gated on purpose — not available to the agent.
480485
init Create a config file (default: ./odek.json)
481486
version Print version and exit
482487

cmd/odek/memory_cmd.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
8+
"github.com/BackendStack21/odek/internal/memory"
9+
)
10+
11+
// memoryCmd handles `odek memory <list|promote> [args]`.
12+
//
13+
// This is the human-gated surface for the episode-memory trust control.
14+
// Episodes whose originating session touched external content (web/http/MCP/
15+
// audio, or reads of sensitive paths) are stored but excluded from recall
16+
// until a human promotes them. Promotion lives HERE — on the CLI — and is
17+
// deliberately NOT exposed as an agent tool, so a prompt-injected agent cannot
18+
// approve its own poisoned memory.
19+
func memoryCmd(args []string) error {
20+
if len(args) == 0 {
21+
fmt.Fprintf(os.Stderr, "Usage: odek memory <list|promote> [args]\n")
22+
return nil
23+
}
24+
25+
dir := expandHome("~/.odek/memory")
26+
store := memory.NewEpisodeStore(dir, nil)
27+
28+
sub := args[0]
29+
subArgs := args[1:]
30+
31+
switch sub {
32+
case "list", "ls", "pending":
33+
pending, err := store.PendingReview()
34+
if err != nil {
35+
return err
36+
}
37+
if len(pending) == 0 {
38+
fmt.Println("No episodes pending review — all stored episodes are recallable.")
39+
return nil
40+
}
41+
fmt.Printf("%d episode(s) pending review (excluded from recall until promoted):\n\n", len(pending))
42+
for _, ep := range pending {
43+
fmt.Printf("• %s (%d turns, %s)\n", ep.SessionID, ep.Turns, ep.CreatedAt.Format("2006-01-02 15:04"))
44+
if len(ep.Provenance.Sources) > 0 {
45+
fmt.Printf(" sources: %s\n", strings.Join(ep.Provenance.Sources, ", "))
46+
}
47+
fmt.Printf(" %s\n\n", ep.Summary)
48+
}
49+
fmt.Println("Review the summary above, then promote with: odek memory promote <session_id>")
50+
return nil
51+
52+
case "promote":
53+
if len(subArgs) == 0 {
54+
return fmt.Errorf("usage: odek memory promote <session_id>")
55+
}
56+
id := subArgs[0]
57+
if err := store.Promote(id); err != nil {
58+
return err
59+
}
60+
fmt.Printf("odek: promoted episode %q — it can now be recalled into future sessions\n", id)
61+
return nil
62+
63+
default:
64+
return fmt.Errorf("unknown memory subcommand %q (expected: list, promote)", sub)
65+
}
66+
}

cmd/odek/memory_cmd_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package main
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
7+
"github.com/BackendStack21/odek/internal/memory"
8+
)
9+
10+
// TestMemoryCmd_ListAndPromote exercises the human-gated promote path end to
11+
// end through the CLI command: a seeded untrusted episode is pending, the
12+
// command promotes it, and the approval is persisted to the on-disk index.
13+
func TestMemoryCmd_ListAndPromote(t *testing.T) {
14+
home := setupTestHome(t)
15+
dir := filepath.Join(home, ".odek", "memory")
16+
17+
es := memory.NewEpisodeStore(dir, nil)
18+
if err := es.WriteWithProvenance("20260108-web", "researched a library", 5,
19+
memory.EpisodeProvenance{Untrusted: true, Sources: []string{"browser"}}); err != nil {
20+
t.Fatalf("seed: %v", err)
21+
}
22+
23+
if err := memoryCmd([]string{"list"}); err != nil {
24+
t.Fatalf("memory list: %v", err)
25+
}
26+
if err := memoryCmd([]string{"promote", "20260108-web"}); err != nil {
27+
t.Fatalf("memory promote: %v", err)
28+
}
29+
30+
fresh := memory.NewEpisodeStore(dir, nil)
31+
idx, err := fresh.ReadIndex()
32+
if err != nil {
33+
t.Fatalf("read index: %v", err)
34+
}
35+
if len(idx) != 1 || !idx[0].Provenance.UserApproved {
36+
t.Errorf("episode not approved after promote: %+v", idx)
37+
}
38+
39+
if err := memoryCmd([]string{"promote", "does-not-exist"}); err == nil {
40+
t.Error("promoting an unknown id should error")
41+
}
42+
if err := memoryCmd([]string{"bogus"}); err == nil {
43+
t.Error("unknown subcommand should error")
44+
}
45+
}
46+
47+
// TestMemoryCmd_ListEmpty: list on a clean home must not error.
48+
func TestMemoryCmd_ListEmpty(t *testing.T) {
49+
setupTestHome(t)
50+
if err := memoryCmd([]string{"list"}); err != nil {
51+
t.Fatalf("memory list on empty home: %v", err)
52+
}
53+
}

docs/CONFIG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,8 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
200200
"llm_extract": true,
201201
"llm_consolidate": true,
202202
"merge_threshold": 0.7,
203-
"add_threshold": 0.3
203+
"add_threshold": 0.3,
204+
"auto_approve_episodes": false
204205
}
205206
}
206207
```
@@ -213,12 +214,13 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
213214
| `buffer_lines` | 20 | Max turn summaries in session buffer |
214215
| `buffer_enabled` | true | Enable the turn-level buffer |
215216
| `merge_on_write` | true | Use go-vector RP similarity to auto-merge related entries |
216-
| `extract_on_end` | true | Extract durable facts via LLM at session end (≥3 turns) |
217+
| `extract_on_end` | true | At session end (≥3 turns), extract a narrative episode summary via LLM for later recall |
217218
| `llm_search` | true | Use LLM to rank episode search results by relevance |
218219
| `llm_extract` | true | Use LLM for end-of-session fact extraction |
219220
| `llm_consolidate` | true | Use LLM to merge related fact entries |
220221
| `merge_threshold` | 0.7 | go-vector cosine threshold for auto-merge (0.0–1.0) |
221222
| `add_threshold` | 0.3 | go-vector cosine threshold for auto-add (0.0–1.0) |
223+
| `auto_approve_episodes` | false | **Security trade-off.** When true, untrusted episodes (sessions that touched web/MCP/out-of-workspace content) are auto-approved at session end so they are recalled without a manual `odek memory promote`. Leaving it `false` keeps the human review gate (recommended). |
222224

223225
## Sub-agent configuration
224226

docs/SECURITY.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,25 @@ Both:
100100

101101
`internal/memory` tracks `EpisodeProvenance{Untrusted, Sources, UserApproved}` for every episode. An episode derived from a session that ingested untrusted content is **stored on disk for audit but never auto-replayed** into future sessions. This stops a single successful injection from becoming a persistent backdoor through the episode pipeline.
102102

103-
To use a tainted episode anyway, the user must explicitly promote it (set `UserApproved=true`).
103+
Taint is decided per tool call by `memory.ToolCallTaints` (the single source of truth, shared with skills):
104+
105+
- **Always untrusted:** `browser`, `http_batch`, `transcribe` (network / opaque-audio content), `session_search` (recall of prior-session transcripts, which may carry earlier-injected text), and any MCP tool (`server__tool`).
106+
- **Path-reading tools** (`read_file`, `search_files`, `multi_grep`, `batch_read`, `json_query`, `head_tail`, `count_lines`, `checksum`, `word_count`, `sort`, `tr`, `diff`, `file_info`, `glob`, `tree`, `base64`) taint when **any** of their path arguments resolves **outside the workspace trust zone** — the workspace dir, the sandbox `/workspace` mount, or `~/.odek`. Reads confined to the workspace stay trusted, so ordinary coding sessions remain recallable; reads of anything else (system/credential paths, home files, sibling repos) taint. The check is a workspace-containment allowlist rather than a sensitive-path denylist, and it resolves symlinks (so e.g. `/etc``/private/etc` on macOS cannot disguise an escape). A malformed argument string is treated conservatively as untrusted. When adding a new file-reading tool, add it to `PathReadingTools`.
107+
108+
To use a tainted episode anyway, the user explicitly promotes it (sets `UserApproved=true`) from the CLI:
109+
110+
```
111+
odek memory list # episodes excluded from recall, with their sources
112+
odek memory promote <session_id> # approve one after reviewing its summary
113+
```
114+
115+
Promotion is **CLI-only and human-gated** — it is deliberately *not* exposed as an agent tool, so a prompt-injected agent cannot self-approve its own poisoned memory.
116+
117+
**Opt-out of the gate (`memory.auto_approve_episodes`, default `false`).** Operators who accept the risk (e.g. a fully sandboxed, single-tenant deployment) can set `auto_approve_episodes: true` to have untrusted episodes stamped `AutoApproved` at session end so they are recalled without a manual promote. This **disables the persistence-injection protection** for episodes — a single successful injection can then influence future sessions automatically — so it is off by default and should stay off in any environment exposed to untrusted input. The on-disk record still keeps `Untrusted=true` and `Sources`, and uses a distinct `AutoApproved` flag (never `UserApproved`) so the audit trail shows the approval was automatic.
104118

105119
### 6. Skill provenance gate
106120

107-
`internal/skills` carries the same provenance model. Skills auto-saved from sessions that touched `browser` / `http_batch` / `read_file` / `search_files` / `multi_grep` / `transcribe` / any MCP tool are tagged with `Provenance.Untrusted=true` and `NeedsReview=true`. The skill loader pins those skills to the Lazy set regardless of their `auto_load` flag.
121+
`internal/skills` carries the same provenance model and shares the exact taint decision (`memory.ToolCallTaints`). Skills auto-saved from sessions that crossed the trust boundary — `browser` / `http_batch` / `transcribe` / any MCP tool, or a `read_file` / `search_files` / `multi_grep` of a **sensitive** path — are tagged with `Provenance.Untrusted=true` and `NeedsReview=true`. The skill loader pins those skills to the Lazy set regardless of their `auto_load` flag.
108122

109123
After reviewing the skill body, promote it:
110124

internal/config/loader.go

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -649,22 +649,22 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
649649
MaxIter: cfg.MaxIter,
650650
System: cfg.System,
651651

652-
SandboxImage: cfg.SandboxImage, // empty = resolve at call site (Dockerfile.odek or alpine:latest)
653-
SandboxNetwork: ifZero(cfg.SandboxNetwork, DefaultSandboxNetwork),
654-
SandboxMemory: cfg.SandboxMemory,
655-
SandboxCPUs: cfg.SandboxCPUs,
656-
SandboxUser: cfg.SandboxUser,
657-
SandboxEnv: cfg.SandboxEnv,
658-
SandboxVolumes: cfg.SandboxVolumes,
659-
Skills: resolveSkills(cfg.Skills),
660-
Dangerous: resolveDangerous(cfg.Dangerous),
661-
Memory: resolveMemory(cfg.Memory),
662-
MCPServers: cfg.MCPServers,
663-
Telegram: resolveTelegram(cfg.Telegram),
664-
Transcription: resolveTranscription(cfg.Transcription),
665-
Schedules: resolveSchedules(cfg.Schedules),
666-
InteractionMode: ifZero(cfg.InteractionMode, "engaging"),
667-
ToolProgress: ifZero(cfg.ToolProgress, "all"),
652+
SandboxImage: cfg.SandboxImage, // empty = resolve at call site (Dockerfile.odek or alpine:latest)
653+
SandboxNetwork: ifZero(cfg.SandboxNetwork, DefaultSandboxNetwork),
654+
SandboxMemory: cfg.SandboxMemory,
655+
SandboxCPUs: cfg.SandboxCPUs,
656+
SandboxUser: cfg.SandboxUser,
657+
SandboxEnv: cfg.SandboxEnv,
658+
SandboxVolumes: cfg.SandboxVolumes,
659+
Skills: resolveSkills(cfg.Skills),
660+
Dangerous: resolveDangerous(cfg.Dangerous),
661+
Memory: resolveMemory(cfg.Memory),
662+
MCPServers: cfg.MCPServers,
663+
Telegram: resolveTelegram(cfg.Telegram),
664+
Transcription: resolveTranscription(cfg.Transcription),
665+
Schedules: resolveSchedules(cfg.Schedules),
666+
InteractionMode: ifZero(cfg.InteractionMode, "engaging"),
667+
ToolProgress: ifZero(cfg.ToolProgress, "all"),
668668
}
669669

670670
// MaxConcurrency: default to 3 if not set
@@ -850,6 +850,9 @@ func resolveMemory(cfg *memory.MemoryConfig) memory.MemoryConfig {
850850
if cfg.MinTurnsForExtraction > 0 {
851851
def.MinTurnsForExtraction = cfg.MinTurnsForExtraction
852852
}
853+
if cfg.AutoApproveEpisodes != nil {
854+
def.AutoApproveEpisodes = cfg.AutoApproveEpisodes
855+
}
853856
return def
854857
}
855858

internal/danger/classifier.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,15 @@ func ClassifyPath(path string) RiskClass {
145145
}
146146
abs = filepath.Clean(abs)
147147

148+
// macOS canonicalizes /etc, /var, and /tmp as symlinks under /private.
149+
// Strip the /private prefix so the sensitivity checks below match
150+
// consistently — e.g. /private/etc/master.passwd must classify the same
151+
// as /etc/master.passwd (system_write), and /private/var/folders/... must
152+
// still resolve to the temp dir (local_write).
153+
if strings.HasPrefix(abs, "/private/") {
154+
abs = strings.TrimPrefix(abs, "/private")
155+
}
156+
148157
for _, prefix := range []string{"/boot", "/dev", "/proc", "/sys", "/mnt", "/media"} {
149158
if strings.HasPrefix(abs, prefix) {
150159
return Destructive

internal/memory/episodes.go

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ func (e *EpisodeStore) Search(query string, limit int) ([]EpisodeMeta, error) {
217217
// EpisodeProvenance exists to close.
218218
filtered := idx[:0:len(idx)]
219219
for _, ep := range idx {
220-
if ep.Provenance.Untrusted && !ep.Provenance.UserApproved {
220+
if ep.Provenance.Untrusted && !ep.Provenance.UserApproved && !ep.Provenance.AutoApproved {
221221
continue
222222
}
223223
filtered = append(filtered, ep)
@@ -244,6 +244,59 @@ func (e *EpisodeStore) Search(query string, limit int) ([]EpisodeMeta, error) {
244244
return ranked, nil
245245
}
246246

247+
// ── Promotion (human-gated escape hatch) ──────────────────────────────
248+
249+
// Promote marks a tainted episode as user-approved so it can be replayed
250+
// into future sessions. This is the human-gated escape hatch for episodes
251+
// whose originating session legitimately touched external content. It is
252+
// intentionally NOT exposed to the agent (only via `odek memory promote`) so
253+
// that a prompt-injected agent cannot self-approve poisoned memory.
254+
//
255+
// Returns an error if the session is unknown or already approved.
256+
func (e *EpisodeStore) Promote(sessionID string) error {
257+
if err := session.ValidateSessionID(sessionID); err != nil {
258+
return fmt.Errorf("memory: episodes promote: %w", err)
259+
}
260+
e.mu.Lock()
261+
defer e.mu.Unlock()
262+
263+
idx, err := e.ReadIndex()
264+
if err != nil {
265+
return err
266+
}
267+
found := false
268+
for i := range idx {
269+
if idx[i].SessionID == sessionID {
270+
found = true
271+
if idx[i].Provenance.UserApproved {
272+
return fmt.Errorf("memory: episode %q is already approved", sessionID)
273+
}
274+
idx[i].Provenance.UserApproved = true
275+
}
276+
}
277+
if !found {
278+
return fmt.Errorf("memory: episode %q not found", sessionID)
279+
}
280+
return e.writeIndex(idx)
281+
}
282+
283+
// PendingReview returns the episodes that are untrusted and not yet
284+
// user-approved — the ones currently excluded from recall that a user may
285+
// want to promote. Ordered newest-first (as ReadIndex returns them).
286+
func (e *EpisodeStore) PendingReview() ([]EpisodeMeta, error) {
287+
idx, err := e.ReadIndex()
288+
if err != nil {
289+
return nil, err
290+
}
291+
var pending []EpisodeMeta
292+
for _, ep := range idx {
293+
if ep.Provenance.Untrusted && !ep.Provenance.UserApproved && !ep.Provenance.AutoApproved {
294+
pending = append(pending, ep)
295+
}
296+
}
297+
return pending, nil
298+
}
299+
247300
// ── Index helpers ─────────────────────────────────────────────────────
248301

249302
// addToIndex appends an entry to the index and writes it.

0 commit comments

Comments
 (0)