package tools import ( "context" "fmt" "io" "log/slog" "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 kgStore store.KnowledgeGraphStore episodicStore store.EpisodicStore tenantStore store.TenantStore workspace string } // Defaults / limits for max_bytes. const ( vaultReadDefaultMaxBytes = 500_000 vaultReadCeilingMaxBytes = 1_048_576 vaultReadUTF8SniffBytes = 8192 vaultReadMaxOutlinks = 20 ) // outlinkAllowedTypes restricts the footer to knowledge-graph links; operational // link types (task_attachment, delegation_attachment, ...) are excluded. var outlinkAllowedTypes = map[string]struct{}{ "wikilink": {}, "reference": {}, } // 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 } // SetKGStore injects an optional KnowledgeGraphStore used for namespace-fallback // lookup when vault lookup misses. nil is safe. func (t *VaultReadTool) SetKGStore(kg store.KnowledgeGraphStore) { t.kgStore = kg } // SetEpisodicStore injects an optional EpisodicStore used for namespace-fallback // lookup when vault lookup misses. nil is safe. func (t *VaultReadTool) SetEpisodicStore(es store.EpisodicStore) { t.episodicStore = es } // SetWorkspace injects the global workspace root (wired at boot). This is the // base for every tenant — the tenant-scoped root is resolved per request in // tenantWorkspace, since one tool instance serves all tenants. func (t *VaultReadTool) SetWorkspace(ws string) { t.workspace = ws } // SetTenantStore injects an optional TenantStore used to resolve the tenant // slug when the request context does not carry one. nil is safe. func (t *VaultReadTool) SetTenantStore(ts store.TenantStore) { t.tenantStore = ts } 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 { // Namespace fallback: the id may belong to a knowledge-graph entity or // an episodic summary. Return a redirect error instead of the generic // "document not found" so the caller can pick the right tool. if redirect := t.namespaceRedirect(ctx, docID.String()); redirect != "" { return ErrorResult(redirect) } 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 the tenant workspace root. fullPath, err := t.resolvePath(t.tenantWorkspace(ctx, tenantID), 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. sniff := content[:min(len(content), vaultReadUTF8SniffBytes)] if len(sniff) < len(content) { // The sniff window ends mid-content, so its last bytes may be the // dangling prefix of a rune the cut split in half. Repair that before // judging, otherwise valid non-ASCII text is reported as binary. sniff = trimPartialTrailingRune(sniff) } if !utf8.Valid(sniff) { 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) } sb.WriteString(t.buildOutlinksFooter(ctx, tenantID.String(), doc.ID)) return NewResult(sb.String()) } // namespaceRedirect probes the KG and episodic stores for the given id. When // a match is found returns a user-facing message telling the caller which tool // to use; otherwise returns "" (caller falls back to "document not found"). // Store lookup errors are swallowed — fallback is best-effort and must never // mask the original not-found signal. func (t *VaultReadTool) namespaceRedirect(ctx context.Context, id string) string { if t.kgStore != nil { agentID := store.AgentIDFromContext(ctx) kgUserID := store.KGUserID(ctx) if ent, err := t.kgStore.GetEntity(ctx, agentID.String(), kgUserID, id); err == nil && ent != nil { slog.Warn("vault_read.namespace_mismatch", "doc_id", id, "source", "kg") return fmt.Sprintf("id %q is a knowledge_graph entity_id, not a vault doc_id — call knowledge_graph_search(entity_id=%q) instead", id, id) } } if t.episodicStore != nil { if ep, err := t.episodicStore.Get(ctx, id); err == nil && ep != nil { slog.Warn("vault_read.namespace_mismatch", "doc_id", id, "source", "episodic") return fmt.Sprintf("id %q is an episodic_id, not a vault doc_id — call memory_expand(id=%q) instead", id, id) } } return "" } // buildOutlinksFooter returns a "## Links" section listing scope-accessible // outlinks (wikilink + reference only), deduped by target, capped at // vaultReadMaxOutlinks. Returns "" when no accessible links remain or on any // store error — footer is additive and must not break the read. func (t *VaultReadTool) buildOutlinksFooter(ctx context.Context, tenantID, docID string) string { links, err := t.vaultStore.GetOutLinks(ctx, tenantID, docID) if err != nil { slog.Warn("vault_read.outlinks.get_failed", "doc_id", docID, "error", err) return "" } if len(links) == 0 { return "" } // Pass 1: type filter + self-link drop + dedup by ToDocID. seen := make(map[string]struct{}, len(links)) filtered := make([]store.VaultLink, 0, len(links)) for _, l := range links { if _, ok := outlinkAllowedTypes[l.LinkType]; !ok { continue } if l.ToDocID == docID { continue } if _, ok := seen[l.ToDocID]; ok { continue } seen[l.ToDocID] = struct{}{} filtered = append(filtered, l) } if len(filtered) == 0 { return "" } // Batch fetch targets. ids := make([]string, 0, len(filtered)) for _, l := range filtered { ids = append(ids, l.ToDocID) } targets, err := t.vaultStore.GetDocumentsByIDs(ctx, tenantID, ids) if err != nil { slog.Warn("vault_read.outlinks.targets_failed", "doc_id", docID, "error", err) return "" } byID := make(map[string]store.VaultDocument, len(targets)) for _, d := range targets { byID[d.ID] = d } // Pass 2: missing-target + scope filter, cap. type keptLink struct { doc store.VaultDocument linkType string } kept := make([]keptLink, 0, vaultReadMaxOutlinks) var overflow int for _, l := range filtered { target, ok := byID[l.ToDocID] if !ok { continue } if !t.allowed(ctx, &target) { continue } if len(kept) >= vaultReadMaxOutlinks { overflow++ continue } kept = append(kept, keptLink{doc: target, linkType: l.LinkType}) } if len(kept) == 0 { return "" } var sb strings.Builder sb.WriteString("\n\n## Links\n") for _, k := range kept { title := k.doc.Title if title == "" { title = k.doc.Path } fmt.Fprintf(&sb, "- %s — id: %s (%s)\n", title, k.doc.ID, k.linkType) } if overflow > 0 { fmt.Fprintf(&sb, "…[%d more links omitted]\n", overflow) } return 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 } if rc.TeamID != *doc.TeamID { return false } // Chat scope: isolated teams restrict cross-chat reads. Docs with // chat_id = NULL are team-wide (legacy or shared-mode writes); docs // with chat_id set must match caller's WorkspaceChatID. if rc.TeamIsolated && doc.ChatID != nil && *doc.ChatID != "" { callerChat := WorkspaceChatIDFromCtx(ctx) if callerChat == "" || callerChat != *doc.ChatID { return false } } return true default: return false } } // tenantWorkspace resolves the tenant-scoped workspace root for this request. // Vault paths are stored relative to that root (e.g. "agents//note.md"), // so joining them onto the global root silently misses every non-master tenant // whose files live under /tenants//. Master tenant is a no-op // inside TenantLayer and keeps using the base root. func (t *VaultReadTool) tenantWorkspace(ctx context.Context, tenantID uuid.UUID) string { slug := store.TenantSlugFromContext(ctx) // Channel- and cron-originated runs may not carry the slug in context. // Without it TenantLayer falls back to a UUID-named directory, which does // not match the slug-named directory the write path creates on disk. if slug == "" && tenantID != store.MasterTenantID && t.tenantStore != nil { if tenant, err := t.tenantStore.GetTenant(ctx, tenantID); err == nil && tenant != nil { slug = tenant.Slug } } return ResolveWorkspace(t.workspace, TenantLayer(tenantID, slug)) } // resolvePath joins the given workspace root with the doc's relative path and // enforces that the fully-resolved path remains strictly under that root. // Passing the tenant root (not the global one) also makes the boundary check // double as cross-tenant isolation. // 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(root, relPath string) (string, error) { wsClean := filepath.Clean(root) 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 { // The cap is a byte offset, so it can land inside a multi-byte rune; // drop the dangling prefix instead of emitting a broken character. return trimPartialTrailingRune(buf[:maxBytes]), true, nil } return buf, false, nil } // trimPartialTrailingRune removes up to utf8.UTFMax-1 trailing bytes from b so // that a cut made at a byte offset does not leave the prefix of a multi-byte // rune dangling. Accented Vietnamese characters take 2-3 bytes, so a plain byte // cut breaks the encoding of otherwise valid text; ASCII never does. // // Only call this on data known to be truncated. On complete content the // trailing bytes belong to the file itself and must stay as they are, so that // genuinely broken bytes are still detected. Stopping after 3 trims keeps the // repair confined to the cut: a longer run of invalid bytes is real corruption. func trimPartialTrailingRune(b []byte) []byte { for trimmed := 0; trimmed < utf8.UTFMax-1 && len(b) > 0 && !utf8.Valid(b); trimmed++ { b = b[:len(b)-1] } return b }