feat(tools): add vault_read tool for shared/personal/team vault docs (#948)

Agents could not read vault docs registered at the tenant workspace root
because read_file enforces a per-agent workspace boundary. vault_read
fetches content by doc_id (from vault_search) with scope-based access
checks (shared/personal/team), a 3-layer text-only gate (DocType +
extension blocklist + UTF-8 sniff), 500KB default cap / 1MB ceiling,
and symlink/hardlink guards shared with read_file. Updated system-prompt
summaries and boundary-error hints so the LLM chains vault_search →
vault_read instead of retrying read_file.
This commit is contained in:
viettranx committed 2026-04-18 12:27:28 +07:00
1 parent 040b0f1944
commit 2338ca53d5
8 files changed
+857 -5

No files matched your search

+7 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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",
+263
View File
@@ -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
}
+402
View File
@@ -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))])
}
}
+4 -1
View File
@@ -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))
}
+178
View File
@@ -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
}