From 7d372b94216e73afe3bb1ad6b63f245d93707922 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:32:03 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(subagents):=20artifact=5Fread=20?= =?UTF-8?q?=E2=80=94=20validated=20on-demand=20content=20access=20(M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-agent result artifacts larger than the 32 KiB inline budget were metadata-only after M1; artifact_read completes the channel: the parent reads full content on demand, by id. - session-scoped registry: delegate_tasks registers every ref that passed fail-closed validation at collation, with the validated path (512 live entries, oldest-first eviction, lazy slots so last-wins re-registration never evicts a live entry) - artifact_read tool (parent-only, gated on SelfTrust=="" via builtinTools): resolves ids internally — the model supplies only the id, never a path — re-verifies the file at read time (janitor/session cleanup may have removed it), returns bytes [offset, offset+limit) (default 64 KiB, hard cap 256 KiB) inside the untrusted boundary with full metadata header; unknown ids list the registered ids; traversal- shaped ids are simply unknown - duplicate ids across tasks: last-wins with an explicit note line in the collated summary RED first: artifact_read_test.go — registry register/lookup/evict/last-wins, happy path with no-path-leak + untrusted wrap, offset/limit slice + truncation flag + cap clamp, unknown-id listing, traversal id, vanished file, parent-only gate, duplicate-registration note. --- cmd/odek/artifact_read_test.go | 194 +++++++++++++++++++++++++ cmd/odek/artifact_read_tool.go | 147 +++++++++++++++++++ cmd/odek/main.go | 7 + cmd/odek/subagent_artifact_registry.go | 172 ++++++++++++++++++++++ cmd/odek/subagent_tool.go | 5 + 5 files changed, 525 insertions(+) create mode 100644 cmd/odek/artifact_read_test.go create mode 100644 cmd/odek/artifact_read_tool.go create mode 100644 cmd/odek/subagent_artifact_registry.go diff --git a/cmd/odek/artifact_read_test.go b/cmd/odek/artifact_read_test.go new file mode 100644 index 0000000..25a357c --- /dev/null +++ b/cmd/odek/artifact_read_test.go @@ -0,0 +1,194 @@ +package main + +// TDD RED phase — M2 artifact_read (SUBAGENT_RESULT_ARTIFACTS_PLAN.md): +// validated artifact refs registered at collation become readable content +// via a parent-only built-in tool. The model supplies ONLY the id — path +// resolution is internal to the registry, so no model input ever reaches +// the filesystem as a path. + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/BackendStack21/odek/internal/artifact" +) + +func regRef(t *testing.T, id, content string) (artifact.Ref, string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, id+".md") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + size := int64(len(content)) + ref := artifact.Ref{ + Schema: artifact.SchemaArtifactRef, ID: id, MediaType: "text/markdown", + URI: "file://" + path, SHA256: expectedSHA(t, content), SizeBytes: &size, + } + return ref, path +} + +func TestArtifactRegistry_RegisterLookupEvict(t *testing.T) { + resetArtifactRegistryForTest() + ref, path := regRef(t, "alpha", "alpha content") + registerSubagentArtifact(artifactEntry{Ref: ref, Path: path, TaskIdx: 0}) + + got, ok := lookupSubagentArtifact("alpha") + if !ok || got.Path != path { + t.Fatalf("lookup failed: %+v ok=%v", got, ok) + } + + // Eviction: cap + 10 more pushes alpha out (oldest first). + for i := 0; i < artifactRegistryCap+10; i++ { + r, p := regRef(t, fmt.Sprintf("fill-%03d", i), "x") + registerSubagentArtifact(artifactEntry{Ref: r, Path: p, TaskIdx: i}) + } + if _, ok := lookupSubagentArtifact("alpha"); ok { + t.Error("oldest entry must be evicted at cap") + } + if _, ok := lookupSubagentArtifact("fill-000"); ok { + t.Error("second-oldest must be evicted too") + } +} + +func TestArtifactRegistry_DuplicateIDLastWins(t *testing.T) { + resetArtifactRegistryForTest() + r1, p1 := regRef(t, "dup", "first") + registerSubagentArtifact(artifactEntry{Ref: r1, Path: p1, TaskIdx: 0}) + r2, p2 := regRef(t, "dup", "second") + dup := registerSubagentArtifact(artifactEntry{Ref: r2, Path: p2, TaskIdx: 1}) + + if !dup { + t.Error("duplicate registration must be reported") + } + got, _ := lookupSubagentArtifact("dup") + if got.Path != p2 || got.TaskIdx != 1 { + t.Errorf("last-wins broken: %+v", got) + } +} + +func TestArtifactReadTool_HappyPath(t *testing.T) { + resetArtifactRegistryForTest() + content := "# Report\n" + strings.Repeat("detail ", 200) + ref, path := regRef(t, "report", content) + registerSubagentArtifact(artifactEntry{Ref: ref, Path: path, TaskIdx: 0}) + + tool := &artifactReadTool{} + tool.SetContext(t.Context()) + got, err := tool.Call(`{"id":"report"}`) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "detail") { + t.Errorf("content missing:\n%s", got) + } + if !strings.Contains(got, "report") || !strings.Contains(got, "text/markdown") { + t.Errorf("metadata header missing:\n%s", got) + } + if strings.Contains(got, path) { + t.Errorf("raw path must never render:\n%s", got) + } + if !strings.Contains(got, "untrusted") { + t.Errorf("artifact content must be untrusted-wrapped:\n%s", got) + } +} + +func TestArtifactReadTool_OffsetLimit(t *testing.T) { + resetArtifactRegistryForTest() + content := strings.Repeat("A", 1000) + ref, path := regRef(t, "blob", content) + registerSubagentArtifact(artifactEntry{Ref: ref, Path: path}) + + tool := &artifactReadTool{} + tool.SetContext(t.Context()) + + got, err := tool.Call(`{"id":"blob","offset":900,"limit":50}`) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, strings.Repeat("A", 50)) { + t.Errorf("offset slice missing:\n%s", got) + } + if !strings.Contains(got, "TRUNCATED") { + t.Errorf("must flag truncation when more remains:\n%s", got) + } + + // Hard cap: limit above the max is clamped, not honored. + if _, err := tool.Call(`{"id":"blob","limit":99999999}`); err != nil { + t.Fatal(err) + } +} + +func TestArtifactReadTool_UnknownIDListsAvailable(t *testing.T) { + resetArtifactRegistryForTest() + for _, id := range []string{"one", "two"} { + ref, path := regRef(t, id, "x") + registerSubagentArtifact(artifactEntry{Ref: ref, Path: path}) + } + + tool := &artifactReadTool{} + tool.SetContext(t.Context()) + got, err := tool.Call(`{"id":"nope"}`) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "one") || !strings.Contains(got, "two") { + t.Errorf("unknown id must list available:\n%s", got) + } + // Traversal-shaped ids are just unknown — never paths. + if _, err := tool.Call(`{"id":"../../etc/passwd"}`); err != nil { + t.Fatal(err) + } +} + +func TestArtifactReadTool_VanishedFile(t *testing.T) { + resetArtifactRegistryForTest() + ref, path := regRef(t, "ghost", "data") + registerSubagentArtifact(artifactEntry{Ref: ref, Path: path}) + os.Remove(path) + + tool := &artifactReadTool{} + tool.SetContext(t.Context()) + got, _ := tool.Call(`{"id":"ghost"}`) + if !strings.Contains(got, "no longer available") { + t.Errorf("vanished artifact must fail friendly:\n%s", got) + } +} + +func TestArtifactReadEnabled_Gate(t *testing.T) { + if !artifactReadEnabled(toolConfig{}) { + t.Error("top-level operator run must get artifact_read") + } + if artifactReadEnabled(toolConfig{SelfTrust: "trusted"}) { + t.Error("sub-agents must NOT get artifact_read (parent-only)") + } + if artifactReadEnabled(toolConfig{SelfTrust: "untrusted"}) { + t.Error("untrusted sub-agents must NOT get artifact_read") + } +} + +func TestRegisterTaskArtifacts_DuplicateNote(t *testing.T) { + resetArtifactRegistryForTest() + dir := t.TempDir() + c1, c2 := "first body", "second body" + p1 := filepath.Join(dir, "dup.md") + p2 := filepath.Join(dir, "dup2.md") + os.WriteFile(p1, []byte(c1), 0o600) + os.WriteFile(p2, []byte(c2), 0o600) + size1, size2 := int64(len(c1)), int64(len(c2)) + raw1 := fmt.Sprintf(`{"status":"success","summary":"ok","artifacts":[{"schema":%q,"id":"dup","uri":"file://%s","media_type":"text/markdown","sha256":%q,"size_bytes":%d}]}`, + artifact.SchemaArtifactRef, p1, expectedSHA(t, c1), size1) + raw2 := fmt.Sprintf(`{"status":"success","summary":"ok","artifacts":[{"schema":%q,"id":"dup","uri":"file://%s","media_type":"text/markdown","sha256":%q,"size_bytes":%d}]}`, + artifact.SchemaArtifactRef, p2, expectedSHA(t, c2), size2) + + if notes := registerTaskArtifacts(raw1, dir, 0); len(notes) != 0 { + t.Errorf("first registration must not note: %v", notes) + } + notes := registerTaskArtifacts(raw2, dir, 1) + if len(notes) != 1 || !strings.Contains(notes[0], "duplicate") { + t.Errorf("duplicate must produce a note: %v", notes) + } +} diff --git a/cmd/odek/artifact_read_tool.go b/cmd/odek/artifact_read_tool.go new file mode 100644 index 0000000..35867c9 --- /dev/null +++ b/cmd/odek/artifact_read_tool.go @@ -0,0 +1,147 @@ +package main + +// artifact_read (M2) — parent-only built-in that resolves a registered +// artifact id to validated content. The model supplies ONLY the id (plus an +// optional byte offset/limit); path resolution happens exclusively through +// the collation-time registry, so no model input ever reaches the +// filesystem as a path. Content is returned inside the untrusted boundary +// like every other child-derived tool result, and ingested into the audit +// log by the standard per-call recorder. + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/BackendStack21/odek" +) + +const ( + // artifactReadDefaultLimit is the per-call byte budget. + artifactReadDefaultLimit = 64 << 10 // 64 KiB + // artifactReadMaxLimit is the hard per-call cap; larger requests clamp. + artifactReadMaxLimit = 256 << 10 // 256 KiB +) + +// artifactReadTool reads registered sub-agent result artifacts by id. +type artifactReadTool struct { + ctxTool +} + +var _ odek.Tool = (*artifactReadTool)(nil) + +func (t *artifactReadTool) Name() string { return "artifact_read" } + +func (t *artifactReadTool) Description() string { + return `Read the full content of a sub-agent result artifact registered this session. Use it when a delegate_tasks result references artifacts whose inlined preview is missing or truncated. + +- id: the artifact id from the delegate_tasks result (required) +- offset: byte offset to start reading from (default 0) +- limit: max bytes to return per call (default 65536; hard cap 262144) + +Paths are resolved internally from the session registry — never pass file paths. Parent-side tool only.` +} + +func (t *artifactReadTool) Schema() any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{ + "type": "string", + "description": "Artifact id from the delegate_tasks result metadata.", + }, + "offset": map[string]any{ + "type": "integer", + "description": "Byte offset to start reading from (default 0).", + }, + "limit": map[string]any{ + "type": "integer", + "description": "Max bytes per call (default 65536, hard cap 262144).", + }, + }, + "required": []string{"id"}, + } +} + +// artifactReadArgs is the tool input contract. +type artifactReadArgs struct { + ID string `json:"id"` + Offset int64 `json:"offset"` + Limit int64 `json:"limit"` +} + +func (t *artifactReadTool) Call(args string) (string, error) { + var in artifactReadArgs + if err := json.Unmarshal([]byte(args), &in); err != nil { + return fmt.Sprintf(`{"error":"parse failed: %v"}`, err), nil + } + if in.ID == "" { + return `{"error":"id is required"}`, nil + } + if in.Offset < 0 { + in.Offset = 0 + } + if in.Limit <= 0 { + in.Limit = artifactReadDefaultLimit + } + if in.Limit > artifactReadMaxLimit { + in.Limit = artifactReadMaxLimit + } + + entry, ok := lookupSubagentArtifact(in.ID) + if !ok { + return fmt.Sprintf(`{"error":"unknown artifact id %q — registered artifacts: %s"}`, in.ID, artifactIDList()), nil + } + + // Re-verify at read time: the janitor backstop or a session delete may + // have removed the subtree since collation. + info, err := os.Stat(entry.Path) + if err != nil || !info.Mode().IsRegular() { + return fmt.Sprintf(`{"error":"artifact %q is no longer available (removed by cleanup)"}`, in.ID), nil + } + if in.Offset >= info.Size() { + return fmt.Sprintf(`{"error":"artifact %q is %d bytes; offset %d is past the end"}`, in.ID, info.Size(), in.Offset), nil + } + + f, err := os.Open(entry.Path) + if err != nil { + return fmt.Sprintf(`{"error":"artifact %q unreadable: %v"}`, in.ID, err), nil + } + defer f.Close() + if _, err := f.Seek(in.Offset, io.SeekStart); err != nil { + return fmt.Sprintf(`{"error":"artifact %q seek failed: %v"}`, in.ID, err), nil + } + // Read one extra byte to detect truncation without a second stat. + data, err := io.ReadAll(io.LimitReader(f, in.Limit+1)) + if err != nil { + return fmt.Sprintf(`{"error":"artifact %q read failed: %v"}`, in.ID, err), nil + } + truncated := int64(len(data)) > in.Limit + if truncated { + data = data[:in.Limit] + } + + size := int64(0) + if entry.Ref.SizeBytes != nil { + size = *entry.Ref.SizeBytes + } + shaPrefix := entry.Ref.SHA256 + if len(shaPrefix) > 12 { + shaPrefix = shaPrefix[:12] + } + + var b strings.Builder + fmt.Fprintf(&b, "artifact %s (%s, %d bytes, sha256:%s) — bytes %d..%d of %d", + entry.Ref.ID, entry.Ref.MediaType, size, shaPrefix, in.Offset, in.Offset+int64(len(data)), info.Size()) + if truncated { + b.WriteString(" — TRUNCATED, call again with offset to continue") + } + b.WriteString("\n\n") + b.Write(data) + + // Child-derived content: inside the untrusted boundary, recorded by the + // per-call audit ingest like every other tool result. + return wrapUntrusted(t.toolCtx(), "artifact_read", b.String()), nil +} diff --git a/cmd/odek/main.go b/cmd/odek/main.go index a726311..9adcd2e 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -2299,6 +2299,13 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d newBrowserTool(dc), } + // artifact_read is registered only for top-level runs (SelfTrust empty): + // sub-agents run in their own process whose artifact registry is always + // empty — parent-only by design (SUBAGENT_RESULT_ARTIFACTS_PLAN.md M2). + if artifactReadEnabled(tcfg) { + tools = append(tools, &artifactReadTool{}) + } + // web_search is registered only when a SearXNG backend is configured — // without a base_url there is no instance to query, so the tool would just // confuse the agent. The Docker compose setup sets this automatically. diff --git a/cmd/odek/subagent_artifact_registry.go b/cmd/odek/subagent_artifact_registry.go new file mode 100644 index 0000000..5be7daa --- /dev/null +++ b/cmd/odek/subagent_artifact_registry.go @@ -0,0 +1,172 @@ +package main + +// Registry of validated sub-agent result artifacts (M2). delegate_tasks +// registers every ref that passed fail-closed validation at collation time, +// together with the validated path; artifact_read resolves ids against this +// registry. The model supplies only the id — paths never cross the model +// boundary in either direction. Same pattern as subagentCtl: process-global, +// mutex-guarded, bounded. + +import ( + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/BackendStack21/odek/internal/artifact" +) + +const ( + // artifactRegistryCap bounds the LIVE registry; oldest entries evict + // first. 8 tasks × 64 refs is the worst single delegate_tasks call, so + // 512 covers a session's recent history comfortably. + artifactRegistryCap = 512 + // maxListedArtifactIDs bounds the available-id list on unknown-id errors. + maxListedArtifactIDs = 16 +) + +// artifactEntry is one validated artifact: the ref as rendered, plus the +// validated (symlink-resolved) path captured at collation time. seq is a +// monotonic registration counter used for lazy queue eviction. +type artifactEntry struct { + Ref artifact.Ref + Path string + TaskIdx int + RegisteredAt time.Time + seq uint64 +} + +// registrySlot pairs an id with the seq that inserted it; stale slots +// (superseded by a last-wins re-registration) are skipped at eviction. +type registrySlot struct { + id string + seq uint64 +} + +var artifactRegistry struct { + mu sync.Mutex + byID map[string]*artifactEntry + order []registrySlot + seq uint64 +} + +func init() { + resetArtifactRegistryForTest() +} + +// resetArtifactRegistryForTest clears the registry. Tests only — production +// code relies on eviction, never on a reset. +func resetArtifactRegistryForTest() { + artifactRegistry.mu.Lock() + defer artifactRegistry.mu.Unlock() + artifactRegistry.byID = map[string]*artifactEntry{} + artifactRegistry.order = nil + artifactRegistry.seq = 0 +} + +// registerSubagentArtifact records a validated artifact under its ref id. +// Last-wins on duplicate ids (a later task overwrites an earlier one); +// returns true when the id was already present so the caller can flag the +// ambiguity in the collated summary. Evicts the oldest LIVE entry at cap; +// superseded queue slots are skipped lazily. +func registerSubagentArtifact(e artifactEntry) bool { + if e.Ref.ID == "" || e.Path == "" { + return false + } + + artifactRegistry.mu.Lock() + defer artifactRegistry.mu.Unlock() + artifactRegistry.seq++ + e.seq = artifactRegistry.seq + if artifactRegistry.byID == nil { + artifactRegistry.byID = map[string]*artifactEntry{} + } + _, dup := artifactRegistry.byID[e.Ref.ID] + artifactRegistry.byID[e.Ref.ID] = &e + artifactRegistry.order = append(artifactRegistry.order, registrySlot{id: e.Ref.ID, seq: e.seq}) + + for len(artifactRegistry.order) > artifactRegistryCap { + front := artifactRegistry.order[0] + artifactRegistry.order = artifactRegistry.order[1:] + // Lazy eviction: pop the slot unconditionally, but only delete the + // live entry when this slot is still its insertion slot (a + // last-wins re-registration owns the id now and has its own slot). + if cur, ok := artifactRegistry.byID[front.id]; ok && cur.seq == front.seq { + delete(artifactRegistry.byID, front.id) + } + } + return dup +} + +// lookupSubagentArtifact resolves an id to its validated entry. +func lookupSubagentArtifact(id string) (artifactEntry, bool) { + artifactRegistry.mu.Lock() + defer artifactRegistry.mu.Unlock() + e, ok := artifactRegistry.byID[id] + if !ok || e == nil { + return artifactEntry{}, false + } + return *e, true +} + +// listSubagentArtifactIDs returns up to maxListedArtifactIDs registered ids +// (oldest first), plus the total live count. +func listSubagentArtifactIDs() ([]string, int) { + artifactRegistry.mu.Lock() + defer artifactRegistry.mu.Unlock() + ids := make([]string, 0, maxListedArtifactIDs) + for _, slot := range artifactRegistry.order { + if len(ids) >= maxListedArtifactIDs { + break + } + if cur, ok := artifactRegistry.byID[slot.id]; ok && cur.seq == slot.seq { + ids = append(ids, slot.id) + } + } + return ids, len(artifactRegistry.byID) +} + +// registerTaskArtifacts validates and registers every artifact of one +// child result against the task's dir, returning human-readable note lines +// for ambiguities (duplicate ids). Validation failures are silently skipped +// — renderArtifacts already flags them in the summary. +func registerTaskArtifacts(raw, dir string, taskIdx int) []string { + var r subagentResult + if err := json.Unmarshal([]byte(raw), &r); err != nil || len(r.Artifacts) == 0 { + return nil + } + var notes []string + for _, ref := range r.Artifacts { + path, err := artifact.Validate(ref, []string{dir}) + if err != nil { + continue + } + dup := registerSubagentArtifact(artifactEntry{Ref: ref, Path: path, TaskIdx: taskIdx}) + if dup { + notes = append(notes, fmt.Sprintf("[artifact] duplicate id %q — artifact_read now resolves to the task %d copy", ref.ID, taskIdx+1)) + } + } + return notes +} + +// artifactReadEnabled reports whether this process gets the artifact_read +// tool: top-level operator runs only (SelfTrust empty). Sub-agents run in +// their own process whose registry is always empty — the tool would be dead +// weight, and the design keeps it parent-only. +func artifactReadEnabled(tcfg toolConfig) bool { + return tcfg.SelfTrust == "" +} + +// artifactIDList renders the bounded available-id list for unknown-id errors. +func artifactIDList() string { + ids, total := listSubagentArtifactIDs() + if total == 0 { + return "no artifacts registered this session" + } + list := strings.Join(ids, ", ") + if total > len(ids) { + list += fmt.Sprintf(" (+%d more)", total-len(ids)) + } + return list +} diff --git a/cmd/odek/subagent_tool.go b/cmd/odek/subagent_tool.go index a12e85f..d6d9733 100644 --- a/cmd/odek/subagent_tool.go +++ b/cmd/odek/subagent_tool.go @@ -305,6 +305,11 @@ func (t *delegateTasksTool) Call(args string) (string, error) { for i, r := range results { fmt.Fprintf(&buf, "─── Task %d: %s ───\n", i+1, truncate(input.Tasks[i].Goal, 60)) buf.WriteString(formatTaskResult(r, dirs[i])) + // M2: validated refs join the session registry so artifact_read can + // resolve them by id later in the turn. + if notes := registerTaskArtifacts(r, dirs[i], i); len(notes) > 0 { + buf.WriteString(strings.Join(notes, "\n") + "\n") + } buf.WriteString("\n\n") } // The aggregated sub-agent output comes from a separate process and may From 75c96856d1eecaffbacbadecd227fb2b548ae12b Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:44:51 +0200 Subject: [PATCH 2/3] feat(subagents): workspace staging + relocation, artifact counts, docs (M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 shipped the artifact channel but children could not actually deliver: the canonical dir (~/.odek/artifacts) is doubly protected from child writes — confineToCWD rejects absolute paths and the danger classifier escalates ~/.odek writes to system_write (denied for approval-less children). M3 resolves this without touching either gate: - children stage deliverables INSIDE the workspace (.odek-artifacts// — an ordinary local_write); the trusted child runner relocates them to the canonical dir before the exit scan (rename with cross-device copy fallback), then hashes/sizes there - wire format unchanged: artifact_root still names the canonical dir (v1.32.0 compatible); the child request carries the workspace-relative staging path via childArtifactNote - subagent_completed events carry artifact_count (count only — hash-only event policy preserved) - docs: CONFIG.md + MAINTENANCE.md (artifacts_max_age_hours knob + env), SECURITY.md (artifact invariants paragraph), SUBAGENTS.md (Result artifacts section), EXTENSIONS.md (sub-agent artifact reuse of odek.artifact-ref/v1) RED first: subagent_staging_test.go — staging path, relocation (move + staging removal), copy fallback via rename-failure hook, missing-staging no-op, event artifact_count presence/absence, note leaks no canonical path. --- cmd/odek/subagent.go | 36 ++++--- cmd/odek/subagent_artifact_registry.go | 91 ++++++++++++++++++ cmd/odek/subagent_staging_test.go | 124 +++++++++++++++++++++++++ cmd/odek/subagent_tool.go | 5 + docs/CONFIG.md | 1 + docs/EXTENSIONS.md | 14 +++ docs/MAINTENANCE.md | 1 + docs/SECURITY.md | 2 + docs/SUBAGENTS.md | 15 +++ 9 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 cmd/odek/subagent_staging_test.go diff --git a/cmd/odek/subagent.go b/cmd/odek/subagent.go index 3dab6b5..dfabea5 100644 --- a/cmd/odek/subagent.go +++ b/cmd/odek/subagent.go @@ -489,7 +489,8 @@ func subagentCmd(args []string) error { var taskMaxRisk string var taskProfile string // capability profile selected by the parent (P4) var taskBudgetBlock *taskBudget // parent's remaining budget (share mode) - var taskArtifactRoot string // per-task artifact dir from the envelope (M1) + var taskArtifactRoot string // per-task artifact dir from the envelope (M1) + var taskTaskID string // envelope task id (M3 staging key) var parentTrust string // parent's own effective trust (P3) var taskID string // telemetry correlation id (protocol-2 parents) var taskProtocol int // telemetry protocol version from the envelope @@ -520,6 +521,7 @@ func subagentCmd(args []string) error { taskBudgetBlock = taskSpec.Budget parentTrust = taskSpec.ParentTrust taskArtifactRoot = taskSpec.ArtifactRoot + taskTaskID = taskSpec.TaskID // Telemetry correlation (sub-agent telemetry M1): protocol-2 parents // stamp a task id; the child echoes it on every stdout record and // frames its final result so the parent cannot misparse. @@ -611,13 +613,12 @@ func subagentCmd(args []string) error { systemMsg := subagentSystem + "\n\n" + buildLifespanBlock(cfg.timeout, cfg.maxIter, resolved.Limits) prompt := buildSubagentRequest(cfg.goal, taskGuidance, cfg.context, taskTrust == "untrusted") if taskArtifactRoot != "" { - // Trusted runner text OUTSIDE any untrusted fence: the dir is - // infrastructure the parent created (same trust as the task-file - // path). Inside the fence it would be neutralized for untrusted - // tasks, silently disabling artifacts exactly where they matter. - prompt += "\n\nArtifact output: any deliverable larger than a short headline must ALSO be written as a file in " + - taskArtifactRoot + - " (use your file tools; plain files, no subdirectories). Files there are delivered to the parent automatically — do not repeat their contents in your final answer." + // Trusted runner text OUTSIDE any untrusted fence: the staging dir + // is workspace-relative infrastructure (an ordinary local_write for + // the child's file tools). Inside the fence it would be neutralized + // for untrusted tasks, silently disabling artifacts exactly where + // they matter. + prompt += childArtifactNote(".odek-artifacts/" + taskTaskID) } // Build tools @@ -835,12 +836,25 @@ func subagentCmd(args []string) error { // Extract files changed from tool calls result.FilesChanged = extractFilesChanged(allMessages) - // M1 artifact scan: refs are runner-built (hashes/sizes measured here, - // never model-fabricated); scan flags ride the summary so the parent + // M1/M3 artifact scan: the child staged deliverables inside the + // workspace (.odek-artifacts// — the only location both + // confineToCWD and the classifier allow it to write); the trusted + // runner relocates them to the canonical dir, then hashes/sizes there + // (never model-fabricated). Scan flags ride the summary so the parent // sees why an artifact is missing. if taskArtifactRoot != "" { - refs, flags := scanArtifacts(taskArtifactRoot, maxArtifactTaskBudget) + var flags []string + cwd, cwdErr := os.Getwd() + if cwdErr != nil { + flags = append(flags, "[artifact] staging lookup failed: "+cwdErr.Error()) + } else { + if _, err := relocateStagingArtifacts(stagingDirFor(cwd, taskTaskID), taskArtifactRoot); err != nil { + flags = append(flags, "[artifact] staging relocation failed: "+err.Error()) + } + } + refs, scanFlags := scanArtifacts(taskArtifactRoot, maxArtifactTaskBudget) result.Artifacts = refs + flags = append(flags, scanFlags...) if len(flags) > 0 { result.Summary = strings.TrimSpace(summary + "\n" + strings.Join(flags, "\n")) } diff --git a/cmd/odek/subagent_artifact_registry.go b/cmd/odek/subagent_artifact_registry.go index 5be7daa..c9ac159 100644 --- a/cmd/odek/subagent_artifact_registry.go +++ b/cmd/odek/subagent_artifact_registry.go @@ -10,6 +10,9 @@ package main import ( "encoding/json" "fmt" + "io" + "os" + "path/filepath" "strings" "sync" "time" @@ -170,3 +173,91 @@ func artifactIDList() string { } return list } + +// ── M3: child staging + trusted-runner relocation ──────────────────── + +// The canonical artifact dir (~/.odek/artifacts) is doubly protected from +// child writes: confineToCWD rejects absolute paths, and the danger +// classifier escalates any ~/.odek write to system_write (denied for +// approval-less children). Children therefore stage deliverables INSIDE +// the workspace — an ordinary local_write both gates allow — and the +// trusted child runner relocates them to the canonical dir before the +// exit scan. Gates untouched; wire format unchanged (artifact_root still +// names the canonical dir, v1.32.0 compatible). +const stagingDirName = ".odek-artifacts" + +// stagingDirFor returns the child-visible staging dir for a task, INSIDE +// the workspace (cwd). +func stagingDirFor(cwd, taskID string) string { + return filepath.Join(cwd, stagingDirName, taskID) +} + +// childArtifactNote builds the trusted runner instruction appended to the +// child's request. It references the workspace-RELATIVE staging path only. +func childArtifactNote(stagingRel string) string { + return "\n\nArtifact output: any deliverable larger than a short headline must ALSO be written as a file in " + + stagingRel + "/ (use your file tools; plain files, no subdirectories). Files there are delivered to the parent automatically — do not repeat their contents in your final answer." +} + +// renameFailureHook lets tests force the copy fallback (rename across +// devices). Production never sets it. +var renameFailureHook func() bool + +// relocateStagingArtifacts moves every top-level regular file from the +// staging dir into the canonical dir (rename, with a copy fallback for +// cross-device workspaces), then removes the staging subtree. Nested +// directories are not artifacts (scanArtifacts skips them) and are +// discarded with the staging tree. A missing staging dir is a no-op. +func relocateStagingArtifacts(staging, canonical string) (int, error) { + entries, err := os.ReadDir(staging) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("artifact staging: %w", err) + } + if err := os.MkdirAll(canonical, 0o700); err != nil { + return 0, fmt.Errorf("artifact canonical dir: %w", err) + } + moved := 0 + for _, e := range entries { + if e.IsDir() { + continue + } + src := filepath.Join(staging, e.Name()) + dst := filepath.Join(canonical, e.Name()) + if renameFailureHook == nil || !renameFailureHook() { + if err := os.Rename(src, dst); err == nil { + moved++ + continue + } + } + if err := copyFileContents(src, dst); err != nil { + return moved, fmt.Errorf("artifact relocate %q: %w", e.Name(), err) + } + os.Remove(src) + moved++ + } + // Only top-level files are artifacts; anything else staged (nested + // dirs) is discarded with the staging tree. + os.RemoveAll(staging) + return moved, nil +} + +// copyFileContents copies src to dst (0600), replacing any existing dst. +func copyFileContents(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} diff --git a/cmd/odek/subagent_staging_test.go b/cmd/odek/subagent_staging_test.go new file mode 100644 index 0000000..8921d02 --- /dev/null +++ b/cmd/odek/subagent_staging_test.go @@ -0,0 +1,124 @@ +package main + +// TDD RED phase — M3 (SUBAGENT_RESULT_ARTIFACTS_PLAN.md): staging + +// relocation, artifact counts on the completed event. +// +// The canonical artifact dir (~/.odek/artifacts) is doubly protected from +// child writes: confineToCWD rejects absolute paths, and the danger +// classifier escalates any ~/.odek write to system_write (denied for +// approval-less children). Children therefore stage deliverables INSIDE +// the workspace (.odek-artifacts// — local_write, allowed), and +// the trusted child runner relocates them to the canonical dir before the +// exit scan. Wire format unchanged: artifact_root still names the +// canonical dir (v1.32.0 compatible). + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestStagingDirFor(t *testing.T) { + cwd := t.TempDir() + got := stagingDirFor(cwd, "task-abc") + want := filepath.Join(cwd, ".odek-artifacts", "task-abc") + if got != want { + t.Errorf("stagingDirFor = %q, want %q", got, want) + } +} + +func TestRelocateStagingArtifacts_MovesFiles(t *testing.T) { + root := t.TempDir() + staging := filepath.Join(root, ".odek-artifacts", "task-1") + canonical := filepath.Join(root, "canonical", "task-1") + if err := os.MkdirAll(staging, 0o700); err != nil { + t.Fatal(err) + } + writeArtifactFile(t, staging, "report.md", "# report") + writeArtifactFile(t, staging, "data.json", "{}") + if err := os.Mkdir(filepath.Join(staging, "sub"), 0o700); err != nil { + t.Fatal(err) + } + writeArtifactFile(t, filepath.Join(staging, "sub"), "nested.txt", "nested") + + n, err := relocateStagingArtifacts(staging, canonical) + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Errorf("want 2 top-level files relocated, got %d", n) + } + for _, f := range []string{"report.md", "data.json"} { + if _, err := os.Stat(filepath.Join(canonical, f)); err != nil { + t.Errorf("%s missing in canonical: %v", f, err) + } + } + if _, err := os.Stat(staging); !os.IsNotExist(err) { + t.Error("staging dir must be removed after relocation") + } +} + +func TestRelocateStagingArtifacts_CopyFallback(t *testing.T) { + root := t.TempDir() + staging := filepath.Join(root, "staging") + canonical := filepath.Join(root, "canonical") + if err := os.MkdirAll(staging, 0o700); err != nil { + t.Fatal(err) + } + writeArtifactFile(t, staging, "report.md", "payload") + + old := renameFailureHook + renameFailureHook = func() bool { return true } // force the copy path + defer func() { renameFailureHook = old }() + + n, err := relocateStagingArtifacts(staging, canonical) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("want 1 file copied, got %d", n) + } + b, err := os.ReadFile(filepath.Join(canonical, "report.md")) + if err != nil || string(b) != "payload" { + t.Errorf("copy fallback lost content: %q (%v)", b, err) + } +} + +func TestRelocateStagingArtifacts_MissingStagingNoop(t *testing.T) { + root := t.TempDir() + n, err := relocateStagingArtifacts(filepath.Join(root, "nope"), filepath.Join(root, "canonical")) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("missing staging must be a no-op, got %d", n) + } +} + +func TestSubagentCompletedEvent_ArtifactCount(t *testing.T) { + ev := subagentCompletedEvent("t1", map[string]any{ + "status": "success", + "artifacts": []any{map[string]any{"id": "a"}, map[string]any{"id": "b"}}, + }, "") + if got, ok := ev.Data["artifact_count"].(int); !ok || got != 2 { + t.Errorf("artifact_count = %v (%T), want 2", ev.Data["artifact_count"], ev.Data["artifact_count"]) + } + + ev = subagentCompletedEvent("t2", map[string]any{"status": "error"}, "error") + if _, ok := ev.Data["artifact_count"]; ok { + t.Error("artifact_count must be absent when the child reported none") + } +} + +func TestChildArtifactNote_RelativeStagingPath(t *testing.T) { + // The instruction shown to the child must use the workspace-relative + // staging path, never the canonical host path. + note := childArtifactNote(".odek-artifacts/task-abc") + if !strings.Contains(note, ".odek-artifacts/task-abc") { + t.Errorf("note missing relative staging path: %s", note) + } + if strings.Contains(note, "/.odek/") { + t.Errorf("note must not leak the canonical host path: %s", note) + } +} diff --git a/cmd/odek/subagent_tool.go b/cmd/odek/subagent_tool.go index d6d9733..af899fe 100644 --- a/cmd/odek/subagent_tool.go +++ b/cmd/odek/subagent_tool.go @@ -872,6 +872,11 @@ func subagentCompletedEvent(taskID string, result map[string]any, fallbackStatus data[k] = v } } + if arts, ok := result["artifacts"].([]any); ok && len(arts) > 0 { + // Count only — refs (hashes) stay out of the event stream per the + // hash-only event policy. + data["artifact_count"] = len(arts) + } } return events.Event{ Type: events.TypeSubagentCompleted, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index fb1880e..7b64956 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -976,6 +976,7 @@ Every field has an `ODEK_MAINTENANCE_*` environment override. | `audit_max_age_days` | `ODEK_MAINTENANCE_AUDIT_MAX_AGE_DAYS` | `14` | Delete `~/.odek/sessions/audit/*.json` records older than this. `0` = keep forever. | | `log_max_mb` | `ODEK_MAINTENANCE_LOG_MAX_MB` | `50` | Rotate `~/.odek/telegram.log` and `~/.odek/schedule.log` larger than this: current log becomes `.1` (one backup generation) and a fresh empty log is started. `0` = no rotation. | | `plans_max_age_days` | `ODEK_MAINTENANCE_PLANS_MAX_AGE_DAYS` | `30` | Delete Telegram plan files (`~/.odek/plans/**/*.md`) older than this; emptied chat directories are removed. `0` = keep forever. | +| `artifacts_max_age_hours` | `ODEK_MAINTENANCE_ARTIFACTS_MAX_AGE_HOURS` | `24` | Delete sub-agent result artifact subtrees (`~/.odek/artifacts//`) older than this. This is the **backstop** — the primary lifecycle is the session-cleanup cascade (deleting a session removes its artifacts immediately). `0` = keep forever. | Downloaded Telegram media (`~/.odek/media/`, including per-chat `chat/` subdirectories) is always swept after 1 hour; that policy is not configurable. diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 518daf6..57d9f36 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -292,3 +292,17 @@ else in this document is opt-in. A reference mock implementing every fixture tool (`echo`, `large_result`, `artifact_result`, `bad_artifact`, `slow`, `error_result`) lives at `internal/mcpclient/testdata/artifact_server.go` and is exercised by `internal/mcpclient/contract_test.go`. + +## Sub-agent result artifacts + +The same artifact schemas power the `delegate_tasks` result channel (no MCP +server involved). The task envelope carries an `artifact_root` naming the +per-task directory the parent created; the child runner relocates staged +workspace files there, measures `sha256`/`size_bytes` itself, and returns +`odek.artifact-ref/v1` references in `subagentResult.artifacts`. The parent +validates every ref fail-closed against the per-task root before rendering — +metadata-only lines in the model context, content inlined for text artifacts +≤ 32 KiB, everything else readable by the parent via the `artifact_read` +tool (id-keyed; paths never enter the model context). See +`docs/SUBAGENTS.md — Result artifacts` and `docs/SECURITY.md` for the +invariants; `SUBAGENT_RESULT_ARTIFACTS_PLAN.md` documents the design. diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index 629b5b1..910c2b3 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -39,6 +39,7 @@ commands (`odek run`, `odek repl`, …) do not run the janitor — use | Sessions | `~/.odek/sessions/*.json` (by `updated_at`) | `sessions_max_age_days` | 30 days | | Audit records | `~/.odek/sessions/audit/*.json` (by mtime) | `audit_max_age_days` | 14 days | | Plans | `~/.odek/plans/**/*.md` (by mtime) | `plans_max_age_days` | 30 days | +| Sub-agent artifacts | `~/.odek/artifacts//` (by mtime) | `artifacts_max_age_hours` | 24 hours (backstop — live removal happens on session delete) | | Telegram media | `~/.odek/media/` (by mtime) | fixed: 1 hour | freed bytes reported | | Logs | `~/.odek/telegram.log`, `~/.odek/schedule.log` | `log_max_mb` | 50 MB (rotated) | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fd1c282..3aebeb9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -244,6 +244,8 @@ The sub-agent process reads both at startup. `applySubagentTrust` clamps its `Da **The sub-agent system prompt is a fixed trust boundary.** It is a code-defined constant composed from a focused-task identity block, the same invariant security pillar the parent prompt carries (`securityPillar`: Safety, Execution provenance, and Indirect Prompt Injection sections), and role amendments that translate principal-facing rules into sub-agent terms: a child has no principal channel and no approvals, so confirmation becomes skip-and-report, justification scope is the declared task, deferred execution requires the task to name the mechanism, and suspected injections are recorded in the final report. There is no `system` field on `delegate_tasks`, and `ODEK_SYSTEM` / config `system` do not apply to sub-agents. All parent-supplied strings (`goal`, `guidance`, `context`) are delivered in the **user request** via `buildSubagentRequest`, never spliced into the system message — a prompt-injection payload that rides in on parent-ingested content can, at worst, become a hostile *request*; it can never redefine the sub-agent's identity or strip its security pillar. When `trust_level: "untrusted"`, the request body is additionally wrapped in a nonce'd `>` fence (with literal-tag neutralisation, same as the untrusted-content boundary) so the model treats it as data. Pillar parity and scanner-cleanliness of the composed prompt are pinned by `cmd/odek/subagent_pillar_test.go`. +**Sub-agent result artifacts** (M1/M2) keep the same boundary. Refs are built by the child **runner** (sha256/size measured there, never model-fabricated); the parent validates every ref fail-closed against the per-task root before rendering — metadata only, raw absolute paths never enter the model context, invalid refs drop with a flag. Content reaches the parent in two ways, both untrusted-wrapped: text artifacts ≤ 32 KiB inline at collation, and `artifact_read` (a parent-only tool — the model supplies an id, never a path; resolution goes through the session registry). Children stage deliverables inside the workspace (`.odek-artifacts//` — an ordinary local write); the trusted runner relocates them into `~/.odek/artifacts/` before scanning, so the `~/.odek` trust anchor and CWD confinement stay intact for the child. Artifact lifecycle: deleting a session removes its artifacts on every deletion path; the janitor backstop sweeps orphans after `artifacts_max_age_hours` (default 24 h). + **API key and secret handoff.** The API key is **not** passed via process environment. It is written to a 0600 temp file that is `unlink()`ed immediately (the FD survives), and the FD is handed to the child via `cmd.ExtraFiles` with an `ODEK_API_KEY_FD=3` env signal. The child reads from FD 3 once and closes it. The key never appears in `/proc//environ`, in crash logs, or to any tool the child invokes that prints its own environment (`env`, `printenv`, etc.). On Windows, where you cannot `unlink` an open file, a 0600 temp file is used and deleted by the parent after the child exits. Beyond the primary key, sub-agent children are spawned with all `~/.odek/secrets.env` values stripped from their environment (`childEnvWithout`), so `TELEGRAM_BOT_TOKEN` and every other injected secret stay unreadable in the child. Sub-agents also inherit the operator's resolved execution budgets, so child spend is bounded. **Stream and file scope.** Sub-agent NDJSON progress streams are capped at 100 000 lines and 100 MiB; exceeding either limit aborts the scan and cancels the sub-agent context, so a runaway or malicious child is killed instead of flooding the parent. `odek subagent --task ` reads its JSON task file and deletes it only when it resides in the system temp directory and matches the `odek-task-*.json` naming convention used by `delegate_tasks` — user-supplied task files are never touched. diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 1470e9f..befce4d 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -469,6 +469,21 @@ Parent synthesizes: "Created 3 files: Total: 8 files changed, 13100 tokens, 5s parallel" ``` +## Result artifacts + +When a sub-agent produces output too large for the headline summary (large reports, dumps, generated fixtures), it writes plain files into its per-task staging directory (`.odek-artifacts//` inside the workspace). The runner relocates them to `~/.odek/artifacts///`, measures sha256/size, and returns `odek.artifact-ref/v1` references with the result. + +The parent sees one metadata line per artifact — id, media type, size, short hash, first-line summary — plus the inlined content of small text artifacts (≤ 32 KiB). Everything larger is readable on demand via `artifact_read`: + +``` +artifact_read({ "id": "report" }) # first 64 KiB +artifact_read({ "id": "report", "offset": 65536 }) # continue paging +``` + +`artifact_read` is a parent-side tool; the model passes an id, never a path — resolution goes through the session registry of validated refs. Refs that fail validation (wrong hash, path escape) are dropped with an explicit flag and never rendered. + +Lifecycle: deleting a session deletes its artifacts (all paths — CLI, API, Telegram, retention sweep); the storage janitor backstop sweeps orphans after `maintenance.artifacts_max_age_hours` (default 24 hours, `0` = keep forever). + ## Tips - **Keep goals small** — one file, one concern per sub-agent. If a goal spans 3 files, it's probably not a good decomposition boundary. From cc5b3c34462f2df7cbcce75c9e252f62e50a05ff Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:45:40 +0200 Subject: [PATCH 3/3] style: gofmt subagent_artifacts_test.go --- cmd/odek/subagent_artifacts_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/odek/subagent_artifacts_test.go b/cmd/odek/subagent_artifacts_test.go index 9c7ddf6..7041a92 100644 --- a/cmd/odek/subagent_artifacts_test.go +++ b/cmd/odek/subagent_artifacts_test.go @@ -141,7 +141,7 @@ func TestFormatTaskResult_ArtifactMetadataAndInline(t *testing.T) { ref := artifact.Ref{ Schema: artifact.SchemaArtifactRef, ID: "report", MediaType: "text/markdown", - URI: "file://" + filepath.Join(dir, "report.md"), + URI: "file://" + filepath.Join(dir, "report.md"), SHA256: expectedSHA(t, content), SizeBytes: ptrInt64(int64(len(content))), Summary: "artifact body line", } @@ -167,7 +167,7 @@ func TestFormatTaskResult_InvalidRefDropped(t *testing.T) { writeArtifactFile(t, dir, "report.md", "real content") ref := artifact.Ref{ Schema: artifact.SchemaArtifactRef, ID: "report", MediaType: "text/markdown", - URI: "file://" + filepath.Join(dir, "report.md"), + URI: "file://" + filepath.Join(dir, "report.md"), SHA256: strings.Repeat("0", 64), // wrong hash — tampered } b, _ := json.Marshal([]artifact.Ref{ref})