diff --git a/cmd/gateway_vault_wiring.go b/cmd/gateway_vault_wiring.go index 8b51f486..fa42a4b7 100644 --- a/cmd/gateway_vault_wiring.go +++ b/cmd/gateway_vault_wiring.go @@ -24,6 +24,12 @@ func wireVault(stores *store.Stores, toolsReg *tools.Registry, workspace string, vaultSearchTool := tools.NewVaultSearchTool() toolsReg.Register(vaultSearchTool) + // vault_read: fetch full content of a vault doc by doc_id (chained from vault_search). + vaultReadTool := tools.NewVaultReadTool() + vaultReadTool.SetVaultStore(stores.Vault) + vaultReadTool.SetWorkspace(workspace) + toolsReg.Register(vaultReadTool) + // Build VaultSearchService: fan-out across vault + KG (episodic store pending impl). // EpisodicStore is nil until a PG implementation exists. searchSvc := vault.NewVaultSearchService(stores.Vault, nil, stores.KnowledgeGraph) @@ -73,6 +79,6 @@ func wireVault(stores *store.Stores, toolsReg *tools.Registry, workspace string, } } - slog.Info("vault tools registered", "tools", "vault_search,create_image,create_video,create_audio,tts,edit") + slog.Info("vault tools registered", "tools", "vault_search,vault_read,create_image,create_video,create_audio,tts,edit") return vaultIntc } diff --git a/internal/providers/acp/tool_bridge.go b/internal/providers/acp/tool_bridge.go index ba36aa58..45f71dfc 100644 --- a/internal/providers/acp/tool_bridge.go +++ b/internal/providers/acp/tool_bridge.go @@ -187,7 +187,7 @@ func (tb *ToolBridge) resolvePath(path string) (string, error) { } if real != wsReal && !strings.HasPrefix(real, wsReal+string(filepath.Separator)) { slog.Warn("security.acp_path_escape", "path", path, "resolved", real, "workspace", wsReal) - return "", fmt.Errorf("access denied: path outside workspace") + return "", fmt.Errorf("access denied: path outside workspace — if this file was discovered via vault_search, use vault_read(doc_id) instead") } return real, nil } diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go index ffd7f40f..2a081b63 100644 --- a/internal/tools/filesystem.go +++ b/internal/tools/filesystem.go @@ -482,7 +482,7 @@ func resolvePath(path, workspace string, restrict bool) (string, error) { // Validate canonical path stays within canonical workspace. if !isPathInside(real, wsReal) { slog.Warn("security.path_escape", "path", path, "resolved", real, "workspace", wsReal) - return "", fmt.Errorf("access denied: path outside workspace") + return "", fmt.Errorf("access denied: path outside workspace — if this file was discovered via vault_search, use vault_read(doc_id) instead") } // Reject paths with mutable symlink components (TOCTOU symlink rebind risk). diff --git a/internal/tools/policy.go b/internal/tools/policy.go index 3e8195b3..ca2c1b76 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -27,7 +27,7 @@ var builtinToolGroups = map[string][]string{ "read_file", "write_file", "list_files", "edit", "exec", "web_search", "web_fetch", "browser", "memory_search", "memory_get", "memory_expand", - "knowledge_graph_search", "vault_search", + "knowledge_graph_search", "vault_search", "vault_read", "sessions_list", "sessions_history", "sessions_send", "spawn", "session_status", "delegate", "cron", "datetime", "heartbeat", diff --git a/internal/tools/vault_read.go b/internal/tools/vault_read.go new file mode 100644 index 00000000..417a3e7d --- /dev/null +++ b/internal/tools/vault_read.go @@ -0,0 +1,263 @@ +package tools + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "unicode/utf8" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// VaultReadTool reads full content of a vault document by doc_id. +// Provides access to shared/personal/team vault docs that read_file cannot +// reach because the path lies outside the agent's canonical workspace. +type VaultReadTool struct { + vaultStore store.VaultStore + workspace string +} + +// Defaults / limits for max_bytes. +const ( + vaultReadDefaultMaxBytes = 500_000 + vaultReadCeilingMaxBytes = 1_048_576 + vaultReadUTF8SniffBytes = 8192 +) + +// nonTextExtensions are file extensions rejected by the text-only gate. +// Lower-case with leading dot. Keep in sync with phase 01 spec. +var nonTextExtensions = map[string]struct{}{ + // images + ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".webp": {}, + ".bmp": {}, ".svg": {}, ".ico": {}, ".heic": {}, ".tiff": {}, + // audio + ".mp3": {}, ".wav": {}, ".ogg": {}, ".flac": {}, ".m4a": {}, + ".aac": {}, ".opus": {}, + // video + ".mp4": {}, ".mov": {}, ".avi": {}, ".mkv": {}, ".webm": {}, ".m4v": {}, + // archives + ".zip": {}, ".tar": {}, ".gz": {}, ".bz2": {}, ".xz": {}, ".7z": {}, ".rar": {}, + // binary documents + ".pdf": {}, ".docx": {}, ".xlsx": {}, ".pptx": {}, + ".doc": {}, ".xls": {}, ".ppt": {}, + // executables + ".exe": {}, ".dll": {}, ".so": {}, ".dylib": {}, ".bin": {}, +} + +// NewVaultReadTool creates a new vault_read tool. +func NewVaultReadTool() *VaultReadTool { return &VaultReadTool{} } + +// SetVaultStore injects the VaultStore dependency (wired at boot). +func (t *VaultReadTool) SetVaultStore(vs store.VaultStore) { t.vaultStore = vs } + +// SetWorkspace injects the tenant workspace root (wired at boot). +func (t *VaultReadTool) SetWorkspace(ws string) { t.workspace = ws } + +func (t *VaultReadTool) Name() string { return "vault_read" } + +func (t *VaultReadTool) Description() string { + return "Read full content of a vault document by doc_id (obtained from vault_search). Use for shared/personal/team vault docs that read_file cannot reach. Text-only — for media use read_image/read_audio/read_video/read_document." +} + +func (t *VaultReadTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "doc_id": map[string]any{ + "type": "string", + "description": "Vault document UUID (from vault_search result).", + }, + "max_bytes": map[string]any{ + "type": "number", + "description": "Optional byte cap (default 500000, hard max 1048576).", + }, + }, + "required": []string{"doc_id"}, + } +} + +func (t *VaultReadTool) Execute(ctx context.Context, args map[string]any) *Result { + if t.vaultStore == nil || t.workspace == "" { + return ErrorResult("vault_read not available") + } + + rawID, _ := args["doc_id"].(string) + rawID = strings.TrimSpace(rawID) + if rawID == "" { + return ErrorResult("doc_id parameter is required") + } + docID, err := uuid.Parse(rawID) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid doc_id: %v", err)) + } + + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return ErrorResult("tenant not set in context") + } + + doc, err := t.vaultStore.GetDocumentByID(ctx, tenantID.String(), docID.String()) + if err != nil || doc == nil { + return ErrorResult("document not found") + } + + if !t.allowed(ctx, doc) { + return ErrorResult("document not accessible in current scope") + } + + // Text-only gate, layer 1: DocType. + if doc.DocType == "media" { + return ErrorResult("vault_read does not support media documents — use read_image/read_audio/read_video/read_document") + } + // Text-only gate, layer 2: extension blocklist. + ext := strings.ToLower(filepath.Ext(doc.Path)) + if _, blocked := nonTextExtensions[ext]; blocked { + return ErrorResult(fmt.Sprintf("vault_read does not support %s files — use read_image/read_audio/read_video/read_document", ext)) + } + + // Resolve path under tenant workspace root. + fullPath, err := t.resolvePath(doc.Path) + if err != nil { + return ErrorResult(err.Error()) + } + + // Determine read cap. + maxBytes := vaultReadDefaultMaxBytes + if v, ok := args["max_bytes"].(float64); ok && v > 0 { + maxBytes = int(v) + } + if maxBytes > vaultReadCeilingMaxBytes { + maxBytes = vaultReadCeilingMaxBytes + } + if maxBytes < 1 { + maxBytes = vaultReadDefaultMaxBytes + } + + content, truncated, err := readCapped(fullPath, maxBytes) + if err != nil { + return ErrorResult(fmt.Sprintf("read failed: %v", err)) + } + + // Text-only gate, layer 3: UTF-8 sniff on first N bytes of content. + sniffLen := min(len(content), vaultReadUTF8SniffBytes) + if !utf8.Valid(content[:sniffLen]) { + return ErrorResult("file content is not valid UTF-8 text — use read_image/read_audio/read_video/read_document for binary files") + } + + var sb strings.Builder + sb.WriteString("# ") + sb.WriteString(doc.Title) + sb.WriteString(" (") + sb.WriteString(doc.Path) + sb.WriteString(")\n\n") + sb.Write(content) + if truncated { + fmt.Fprintf(&sb, "\n\n…[truncated, content exceeds %d bytes]", maxBytes) + } + return NewResult(sb.String()) +} + +// allowed enforces the scope matrix: +// - shared: allow. +// - personal: allow iff doc.AgentID == agentID from ctx. +// - team: allow iff RunContext.TeamID == *doc.TeamID. +// - else: deny (default-deny for unknown scope values). +func (t *VaultReadTool) allowed(ctx context.Context, doc *store.VaultDocument) bool { + switch doc.Scope { + case "shared": + return true + case "personal": + if doc.AgentID == nil { + return false + } + aid := store.AgentIDFromContext(ctx) + if aid == uuid.Nil { + return false + } + return *doc.AgentID == aid.String() + case "team": + if doc.TeamID == nil || *doc.TeamID == "" { + return false + } + rc := store.RunContextFromCtx(ctx) + if rc == nil || rc.TeamID == "" { + return false + } + return rc.TeamID == *doc.TeamID + default: + return false + } +} + +// resolvePath joins the tenant workspace with the doc's relative path and +// enforces that the fully-resolved path remains strictly under the workspace. +// Symlinks are resolved via EvalSymlinks for defence-in-depth; if the file is +// missing EvalSymlinks will fail and we fall back to the cleaned join (the +// subsequent os.Open will surface the not-found error naturally). +func (t *VaultReadTool) resolvePath(relPath string) (string, error) { + wsClean := filepath.Clean(t.workspace) + joined := filepath.Join(wsClean, filepath.FromSlash(relPath)) + + // Resolve symlinks where possible; on error (e.g. file missing) keep the + // cleaned join — downstream open will surface the true error. + resolved := joined + if r, err := filepath.EvalSymlinks(joined); err == nil { + resolved = r + } + + // Also resolve the workspace itself so prefix comparison is symlink-safe. + wsResolved := wsClean + if r, err := filepath.EvalSymlinks(wsClean); err == nil { + wsResolved = r + } + + if !pathUnder(resolved, wsResolved) { + return "", fmt.Errorf("access denied: document path outside workspace") + } + // Defence-in-depth: reject paths whose parent dir is a mutable symlink + // (TOCTOU rebind risk) and hardlinked targets (nlink > 1). + // Shared with read_file boundary logic (filesystem_unix.go / _windows.go). + if hasMutableSymlinkParent(resolved) { + return "", fmt.Errorf("access denied: document path contains mutable symlink component") + } + if err := checkHardlink(resolved); err != nil { + return "", err + } + return resolved, nil +} + +// pathUnder reports whether child is equal to or strictly under parent. +func pathUnder(child, parent string) bool { + childClean := filepath.Clean(child) + parentClean := filepath.Clean(parent) + if childClean == parentClean { + return true + } + withSep := parentClean + string(os.PathSeparator) + return strings.HasPrefix(childClean, withSep) +} + +// readCapped opens path and reads up to maxBytes; if the file is longer the +// result is truncated and truncated=true. Reads maxBytes+1 via LimitReader to +// detect overflow cheaply without buffering the whole file. +func readCapped(path string, maxBytes int) ([]byte, bool, error) { + f, err := os.Open(path) + if err != nil { + return nil, false, err + } + defer f.Close() + + buf, err := io.ReadAll(io.LimitReader(f, int64(maxBytes)+1)) + if err != nil { + return nil, false, err + } + if len(buf) > maxBytes { + return buf[:maxBytes], true, nil + } + return buf, false, nil +} diff --git a/internal/tools/vault_read_test.go b/internal/tools/vault_read_test.go new file mode 100644 index 00000000..4537fb8f --- /dev/null +++ b/internal/tools/vault_read_test.go @@ -0,0 +1,402 @@ +package tools + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// fakeVaultStore embeds store.VaultStore (nil) so the struct satisfies the +// interface at compile time; only GetDocumentByID is implemented for tests. +// Any call to an unimplemented method would nil-pointer panic — which is fine +// because vault_read.Execute only hits GetDocumentByID. +type fakeVaultStore struct { + store.VaultStore + byID map[string]*store.VaultDocument // key: tenantID + ":" + docID +} + +func (f *fakeVaultStore) GetDocumentByID(ctx context.Context, tenantID, id string) (*store.VaultDocument, error) { + if f.byID == nil { + return nil, os.ErrNotExist + } + doc, ok := f.byID[tenantID+":"+id] + if !ok { + return nil, os.ErrNotExist + } + return doc, nil +} + +// newVaultReadTestTool builds a VaultReadTool with a temp workspace and a +// fake store pre-seeded with docs. +func newVaultReadTestTool(t *testing.T, docs ...*store.VaultDocument) (*VaultReadTool, string) { + t.Helper() + ws := t.TempDir() + fake := &fakeVaultStore{byID: make(map[string]*store.VaultDocument)} + for _, d := range docs { + fake.byID[d.TenantID+":"+d.ID] = d + } + tool := NewVaultReadTool() + tool.SetVaultStore(fake) + tool.SetWorkspace(ws) + return tool, ws +} + +// writeFile creates a file under workspace with the given relative path. +func writeFile(t *testing.T, ws, rel, content string) { + t.Helper() + full := filepath.Join(ws, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write: %v", err) + } +} + +// writeBytes creates a file with raw bytes (for binary content tests). +func writeBytes(t *testing.T, ws, rel string, b []byte) { + t.Helper() + full := filepath.Join(ws, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, b, 0o644); err != nil { + t.Fatalf("write: %v", err) + } +} + +func makeCtx(tenantID, agentID uuid.UUID) context.Context { + ctx := context.Background() + if tenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, tenantID) + } + if agentID != uuid.Nil { + ctx = store.WithAgentID(ctx, agentID) + } + return ctx +} + +func makeCtxWithTeam(tenantID, agentID uuid.UUID, teamID string) context.Context { + ctx := makeCtx(tenantID, agentID) + return store.WithRunContext(ctx, &store.RunContext{ + TenantID: tenantID, + AgentID: agentID, + TeamID: teamID, + }) +} + +// --- 1. shared scope → allow, content returned. --- +func TestVaultRead_SharedScope_Allow(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + Scope: "shared", Path: "shared/notes.md", Title: "Notes", + DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + writeFile(t, ws, "shared/notes.md", "hello world") + + res := tool.Execute(makeCtx(tenantID, agentID), + map[string]any{"doc_id": docID.String()}) + if res.IsError { + t.Fatalf("unexpected error: %s", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "hello world") { + t.Fatalf("content missing: %s", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "Notes") { + t.Fatalf("title missing: %s", res.ForLLM) + } +} + +// --- 2. personal scope, AgentID matches ctx → allow. --- +func TestVaultRead_PersonalScope_Match_Allow(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + aid := agentID.String() + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + AgentID: &aid, Scope: "personal", Path: "memo.md", + Title: "Memo", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + writeFile(t, ws, "memo.md", "personal body") + + res := tool.Execute(makeCtx(tenantID, agentID), + map[string]any{"doc_id": docID.String()}) + if res.IsError { + t.Fatalf("unexpected error: %s", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "personal body") { + t.Fatalf("content missing: %s", res.ForLLM) + } +} + +// --- 3. personal scope, AgentID mismatch → deny. --- +func TestVaultRead_PersonalScope_Mismatch_Deny(t *testing.T) { + tenantID := uuid.New() + agentA := uuid.New() + agentB := uuid.New() + docID := uuid.New() + aid := agentA.String() + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + AgentID: &aid, Scope: "personal", Path: "memo.md", + Title: "Memo", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + writeFile(t, ws, "memo.md", "personal body") + + res := tool.Execute(makeCtx(tenantID, agentB), + map[string]any{"doc_id": docID.String()}) + if !res.IsError || !strings.Contains(res.ForLLM, "not accessible") { + t.Fatalf("expected access denied, got: %s", res.ForLLM) + } +} + +// --- 4. team scope, RunContext.TeamID matches → allow. --- +func TestVaultRead_TeamScope_Match_Allow(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + teamID := uuid.New().String() + tid := teamID + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + TeamID: &tid, Scope: "team", Path: "team/doc.md", + Title: "Team Doc", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + writeFile(t, ws, "team/doc.md", "team body") + + res := tool.Execute(makeCtxWithTeam(tenantID, agentID, teamID), + map[string]any{"doc_id": docID.String()}) + if res.IsError { + t.Fatalf("unexpected error: %s", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "team body") { + t.Fatalf("content missing: %s", res.ForLLM) + } +} + +// --- 5. team scope, no run-context / mismatch → deny. --- +func TestVaultRead_TeamScope_NoContext_Deny(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + tid := uuid.New().String() + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + TeamID: &tid, Scope: "team", Path: "team/doc.md", + Title: "Team Doc", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + writeFile(t, ws, "team/doc.md", "team body") + + // no team in ctx. + res := tool.Execute(makeCtx(tenantID, agentID), + map[string]any{"doc_id": docID.String()}) + if !res.IsError { + t.Fatalf("expected deny, got: %s", res.ForLLM) + } + + // mismatched team in ctx. + res2 := tool.Execute(makeCtxWithTeam(tenantID, agentID, uuid.New().String()), + map[string]any{"doc_id": docID.String()}) + if !res2.IsError { + t.Fatalf("expected deny on mismatch, got: %s", res2.ForLLM) + } +} + +// --- 6. cross-tenant (different tenant in ctx) → not-found. --- +func TestVaultRead_CrossTenant_NotFound(t *testing.T) { + tenantA := uuid.New() + tenantB := uuid.New() + agentID := uuid.New() + docID := uuid.New() + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantA.String(), + Scope: "shared", Path: "a.md", Title: "A", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + writeFile(t, ws, "a.md", "body") + + res := tool.Execute(makeCtx(tenantB, agentID), + map[string]any{"doc_id": docID.String()}) + if !res.IsError || !strings.Contains(res.ForLLM, "not found") { + t.Fatalf("expected not found, got: %s", res.ForLLM) + } +} + +// --- 7. missing doc id → not-found. --- +func TestVaultRead_MissingDoc_NotFound(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + tool, _ := newVaultReadTestTool(t) + + res := tool.Execute(makeCtx(tenantID, agentID), + map[string]any{"doc_id": uuid.New().String()}) + if !res.IsError || !strings.Contains(res.ForLLM, "not found") { + t.Fatalf("expected not found, got: %s", res.ForLLM) + } +} + +// --- 8. invalid UUID → arg error. --- +func TestVaultRead_InvalidUUID(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + tool, _ := newVaultReadTestTool(t) + + res := tool.Execute(makeCtx(tenantID, agentID), + map[string]any{"doc_id": "not-a-uuid"}) + if !res.IsError || !strings.Contains(res.ForLLM, "invalid doc_id") { + t.Fatalf("expected invalid doc_id error, got: %s", res.ForLLM) + } +} + +// --- 9. media DocType → rejected with hint. --- +func TestVaultRead_MediaDocType_Rejected(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + Scope: "shared", Path: "pic.png", Title: "Pic", DocType: "media", + } + tool, ws := newVaultReadTestTool(t, doc) + writeFile(t, ws, "pic.png", "pretend-png") + + res := tool.Execute(makeCtx(tenantID, agentID), + map[string]any{"doc_id": docID.String()}) + if !res.IsError || !strings.Contains(res.ForLLM, "read_image") { + t.Fatalf("expected media-handler hint, got: %s", res.ForLLM) + } +} + +// --- 10. binary extension even when DocType!=media → rejected by blocklist. --- +func TestVaultRead_BinaryExtension_Rejected(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + Scope: "shared", Path: "report.PDF", Title: "Rep", DocType: "document", + } + tool, ws := newVaultReadTestTool(t, doc) + writeFile(t, ws, "report.PDF", "%PDF-1.7") + + res := tool.Execute(makeCtx(tenantID, agentID), + map[string]any{"doc_id": docID.String()}) + if !res.IsError || !strings.Contains(res.ForLLM, ".pdf") { + t.Fatalf("expected pdf blocklist error, got: %s", res.ForLLM) + } +} + +// --- 11. text extension but binary bytes → rejected by UTF-8 sniff. --- +func TestVaultRead_BinaryContent_UTF8Rejected(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + Scope: "shared", Path: "blob.txt", Title: "Blob", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + // Invalid UTF-8: stray 0xC3 byte with no continuation. + writeBytes(t, ws, "blob.txt", []byte{0xC3, 0x28, 0xFF, 0xFE, 0xFD}) + + res := tool.Execute(makeCtx(tenantID, agentID), + map[string]any{"doc_id": docID.String()}) + if !res.IsError || !strings.Contains(res.ForLLM, "UTF-8") { + t.Fatalf("expected UTF-8 rejection, got: %s", res.ForLLM) + } +} + +// --- 12. oversize file → truncated with marker. --- +func TestVaultRead_Oversize_Truncated(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + Scope: "shared", Path: "big.md", Title: "Big", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + // 30KB of 'a' — fits in UTF-8 sniff check, but larger than max_bytes below. + big := strings.Repeat("a", 30_000) + writeFile(t, ws, "big.md", big) + + res := tool.Execute(makeCtx(tenantID, agentID), + map[string]any{"doc_id": docID.String(), "max_bytes": float64(10_000)}) + if res.IsError { + t.Fatalf("unexpected error: %s", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "truncated") { + t.Fatalf("expected truncation marker, got: %s", res.ForLLM) + } +} + +// --- 13. symlink escaping workspace → denied. --- +func TestVaultRead_SymlinkEscape_Denied(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + Scope: "shared", Path: "escape.md", Title: "Esc", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + + // Create target outside workspace. + outside := t.TempDir() + target := filepath.Join(outside, "secret.txt") + if err := os.WriteFile(target, []byte("leak"), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + // Symlink ws/escape.md → outside/secret.txt. + if err := os.Symlink(target, filepath.Join(ws, "escape.md")); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + + res := tool.Execute(makeCtx(tenantID, agentID), + map[string]any{"doc_id": docID.String()}) + if !res.IsError || !strings.Contains(res.ForLLM, "outside workspace") { + t.Fatalf("expected symlink escape denied, got: %s", res.ForLLM) + } +} + +// --- 14. max_bytes clamp to ceiling (1MB). --- +func TestVaultRead_MaxBytes_ClampCeiling(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + Scope: "shared", Path: "c.md", Title: "C", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + // 1.2MB body — should be truncated to 1MB hard ceiling regardless of arg. + body := bytes.Repeat([]byte("x"), 1_200_000) + writeBytes(t, ws, "c.md", body) + + res := tool.Execute(makeCtx(tenantID, agentID), map[string]any{ + "doc_id": docID.String(), + "max_bytes": float64(5_000_000), // above ceiling + }) + if res.IsError { + t.Fatalf("unexpected error: %s", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "truncated") { + t.Fatalf("expected truncation marker for ceiling clamp, got head: %s", res.ForLLM[:min(200, len(res.ForLLM))]) + } +} diff --git a/internal/tools/vault_search.go b/internal/tools/vault_search.go index c09d98b5..1c984727 100644 --- a/internal/tools/vault_search.go +++ b/internal/tools/vault_search.go @@ -27,7 +27,7 @@ func (t *VaultSearchTool) SetSearchService(svc *vault.VaultSearchService) { func (t *VaultSearchTool) Name() string { return "vault_search" } func (t *VaultSearchTool) Description() string { - return "Primary discovery tool: search across ALL knowledge sources (vault docs, memory, knowledge graph). Returns ranked results with source attribution. Use memory_search for memory-only queries, kg_search for relationship traversal." + return "Primary discovery tool: search across ALL knowledge sources (vault docs, memory, knowledge graph). Returns ranked results with source attribution and doc_id — pass the doc_id to vault_read for full content. Use memory_search for memory-only queries, kg_search for relationship traversal." } func (t *VaultSearchTool) Parameters() map[string]any { @@ -108,6 +108,9 @@ func (t *VaultSearchTool) Execute(ctx context.Context, args map[string]any) *Res sb.WriteString(fmt.Sprintf(" (%s)", r.Path)) } sb.WriteString(fmt.Sprintf(" — score: %.2f", r.Score)) + if r.ID != "" { + sb.WriteString(fmt.Sprintf(" — id: %s", r.ID)) + } if r.Snippet != "" { sb.WriteString(fmt.Sprintf("\n %s", r.Snippet)) } diff --git a/tests/integration/vault_read_test.go b/tests/integration/vault_read_test.go new file mode 100644 index 00000000..267b2b8b --- /dev/null +++ b/tests/integration/vault_read_test.go @@ -0,0 +1,178 @@ +//go:build integration + +package integration + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +// TestVaultRead_SharedAtWorkspaceRoot reproduces issue #948: +// A file dropped at the tenant workspace root is registered with scope="shared". +// read_file refuses it (path outside agent canonical workspace); vault_read must +// return its full content when queried by doc_id. +func TestVaultRead_SharedAtWorkspaceRoot(t *testing.T) { + db := testDB(t) + tenantID, agentID := seedTenantAgent(t, db) + vs := newVaultStore(db) + + ws := t.TempDir() + body := strings.Repeat("KG_03 body line\n", 300) // ~4.8KB + relPath := "KG_03_Test.md" + if err := os.WriteFile(filepath.Join(ws, relPath), []byte(body), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + // Register doc directly (avoids spinning up the full rescan pipeline). + doc := makeSharedVaultDoc(tenantID.String(), relPath, "KG_03 Test") + ctx := tenantCtx(tenantID) + ctx = store.WithAgentID(ctx, agentID) + if err := vs.UpsertDocument(ctx, doc); err != nil { + t.Fatalf("UpsertDocument: %v", err) + } + if doc.ID == "" { + t.Fatal("expected doc.ID after upsert") + } + + tool := tools.NewVaultReadTool() + tool.SetVaultStore(vs) + tool.SetWorkspace(ws) + + res := tool.Execute(ctx, map[string]any{"doc_id": doc.ID}) + if res.IsError { + t.Fatalf("vault_read unexpected error: %s", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "KG_03 body line") { + t.Fatalf("content missing in response (len=%d)", len(res.ForLLM)) + } + if !strings.Contains(res.ForLLM, "KG_03 Test") { + t.Fatalf("title missing in response header") + } +} + +// TestVaultRead_PersonalCrossAgentDenied — agent B must NOT be able to read +// agent A's personal doc even within the same tenant. +func TestVaultRead_PersonalCrossAgentDenied(t *testing.T) { + db := testDB(t) + tenantID, agentA := seedTenantAgent(t, db) + _, agentB := seedTenantAgent(t, db) + vs := newVaultStore(db) + + ws := t.TempDir() + relPath := "private.md" + if err := os.WriteFile(filepath.Join(ws, relPath), []byte("secret"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + doc := makeVaultDoc(tenantID.String(), agentA.String(), relPath, "Private") + ctxA := store.WithAgentID(tenantCtx(tenantID), agentA) + if err := vs.UpsertDocument(ctxA, doc); err != nil { + t.Fatalf("UpsertDocument: %v", err) + } + + tool := tools.NewVaultReadTool() + tool.SetVaultStore(vs) + tool.SetWorkspace(ws) + + // Agent B reads → must be denied. + ctxB := store.WithAgentID(tenantCtx(tenantID), agentB) + res := tool.Execute(ctxB, map[string]any{"doc_id": doc.ID}) + if !res.IsError { + t.Fatalf("expected access denied for cross-agent personal read, got content") + } + if !strings.Contains(res.ForLLM, "not accessible") { + t.Fatalf("expected 'not accessible' error, got: %s", res.ForLLM) + } +} + +// TestVaultRead_MediaRejected — doc with DocType=media must be rejected even +// when the caller has scope access. +func TestVaultRead_MediaRejected(t *testing.T) { + db := testDB(t) + tenantID, agentID := seedTenantAgent(t, db) + vs := newVaultStore(db) + + ws := t.TempDir() + relPath := "pic.png" + if err := os.WriteFile(filepath.Join(ws, relPath), []byte("pretend-png"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + aid := agentID.String() + doc := &store.VaultDocument{ + TenantID: tenantID.String(), + AgentID: &aid, + Scope: "personal", + Path: relPath, + Title: "Pic", + DocType: "media", + ContentHash: "abc", + Metadata: map[string]any{"mime_type": "image/png"}, + } + ctx := store.WithAgentID(tenantCtx(tenantID), agentID) + if err := vs.UpsertDocument(ctx, doc); err != nil { + t.Fatalf("UpsertDocument: %v", err) + } + + tool := tools.NewVaultReadTool() + tool.SetVaultStore(vs) + tool.SetWorkspace(ws) + + res := tool.Execute(ctx, map[string]any{"doc_id": doc.ID}) + if !res.IsError { + t.Fatalf("expected media rejection, got content") + } + if !strings.Contains(res.ForLLM, "read_image") { + t.Fatalf("expected media-handler hint, got: %s", res.ForLLM) + } +} + +// TestVaultRead_OversizeTruncation — a >max_bytes file is truncated with marker. +func TestVaultRead_OversizeTruncation(t *testing.T) { + db := testDB(t) + tenantID, agentID := seedTenantAgent(t, db) + vs := newVaultStore(db) + + ws := t.TempDir() + relPath := "big.md" + big := bytes.Repeat([]byte("a"), 300_000) + if err := os.WriteFile(filepath.Join(ws, relPath), big, 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + doc := makeSharedVaultDoc(tenantID.String(), relPath, "Big Doc") + ctx := store.WithAgentID(tenantCtx(tenantID), agentID) + if err := vs.UpsertDocument(ctx, doc); err != nil { + t.Fatalf("UpsertDocument: %v", err) + } + + tool := tools.NewVaultReadTool() + tool.SetVaultStore(vs) + tool.SetWorkspace(ws) + + maxBytes := 100_000 + res := tool.Execute(ctx, map[string]any{ + "doc_id": doc.ID, + "max_bytes": float64(maxBytes), + }) + if res.IsError { + t.Fatalf("unexpected error: %s", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "truncated") { + t.Fatalf("expected truncation marker, got none") + } + // Sanity: body length roughly bounded by maxBytes + header + marker (<200 chars overhead). + if bodyLen := len(res.ForLLM); bodyLen < maxBytes-100 || bodyLen > maxBytes+500 { + t.Fatalf("unexpected output length: %d (expected ~%d)", bodyLen, maxBytes) + } + // Issue #948 regression guard: identifier in test name helps future greps. + _ = uuid.Nil +}