mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 20:14:50 +00:00
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:
1 parent
040b0f1944
commit
2338ca53d5
8 files changed
+857
-5
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))])
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user