mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 16:13:47 +00:00
656 lines
21 KiB
Go
656 lines
21 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// helper to create a temp workspace with files
|
|
func setupWorkspace(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
// Create a normal file
|
|
if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Create a subdirectory
|
|
sub := filepath.Join(dir, "subdir")
|
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sub, "nested.txt"), []byte("nested"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func TestResolvePath_NormalFile(t *testing.T) {
|
|
ws := setupWorkspace(t)
|
|
resolved, err := resolvePath("hello.txt", ws, true)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
if filepath.Base(resolved) != "hello.txt" {
|
|
t.Fatalf("expected hello.txt, got: %s", resolved)
|
|
}
|
|
}
|
|
|
|
func TestResolvePath_NestedFile(t *testing.T) {
|
|
ws := setupWorkspace(t)
|
|
resolved, err := resolvePath("subdir/nested.txt", ws, true)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
if filepath.Base(resolved) != "nested.txt" {
|
|
t.Fatalf("expected nested.txt, got: %s", resolved)
|
|
}
|
|
}
|
|
|
|
func TestResolvePath_AbsolutePath(t *testing.T) {
|
|
ws := setupWorkspace(t)
|
|
absPath := filepath.Join(ws, "hello.txt")
|
|
resolved, err := resolvePath(absPath, ws, true)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
if resolved != absPath {
|
|
// canonical path might differ if ws has symlinks (e.g. /tmp on macOS)
|
|
realAbs, _ := filepath.EvalSymlinks(absPath)
|
|
if resolved != realAbs {
|
|
t.Fatalf("expected %s or %s, got: %s", absPath, realAbs, resolved)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolvePath_TraversalBlocked(t *testing.T) {
|
|
ws := setupWorkspace(t)
|
|
_, err := resolvePath("../../etc/passwd", ws, true)
|
|
if err == nil {
|
|
t.Fatal("expected error for path traversal, got nil")
|
|
}
|
|
}
|
|
|
|
func TestResolvePath_AbsoluteEscapeBlocked(t *testing.T) {
|
|
ws := setupWorkspace(t)
|
|
outside := filepath.Join(t.TempDir(), "passwd")
|
|
if err := os.WriteFile(outside, []byte("secret"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := resolvePath(outside, ws, true)
|
|
if err == nil {
|
|
t.Fatal("expected error for absolute path outside workspace, got nil")
|
|
}
|
|
}
|
|
|
|
func TestResolvePath_SymlinkEscapeBlocked(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require special privileges on Windows")
|
|
}
|
|
ws := setupWorkspace(t)
|
|
|
|
// Create a file outside workspace
|
|
outside := t.TempDir()
|
|
secret := filepath.Join(outside, "secret.txt")
|
|
if err := os.WriteFile(secret, []byte("secret"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create symlink inside workspace pointing outside
|
|
link := filepath.Join(ws, "evil_link")
|
|
if err := os.Symlink(secret, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := resolvePath("evil_link", ws, true)
|
|
if err == nil {
|
|
t.Fatal("expected error for symlink escaping workspace, got nil")
|
|
}
|
|
}
|
|
|
|
func TestResolvePath_SymlinkInsideWorkspaceAllowed(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require special privileges on Windows")
|
|
}
|
|
ws := setupWorkspace(t)
|
|
|
|
// Create symlink pointing to a file within workspace
|
|
target := filepath.Join(ws, "hello.txt")
|
|
link := filepath.Join(ws, "good_link")
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resolved, err := resolvePath("good_link", ws, true)
|
|
if err != nil {
|
|
t.Fatalf("expected success for symlink within workspace, got: %v", err)
|
|
}
|
|
|
|
// Should resolve to canonical path of target
|
|
realTarget, _ := filepath.EvalSymlinks(target)
|
|
if resolved != realTarget {
|
|
t.Fatalf("expected %s, got: %s", realTarget, resolved)
|
|
}
|
|
}
|
|
|
|
func TestResolvePath_BrokenSymlinkBlocked(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require special privileges on Windows")
|
|
}
|
|
ws := setupWorkspace(t)
|
|
|
|
// Create symlink pointing to non-existent file outside workspace
|
|
link := filepath.Join(ws, "broken_link")
|
|
if err := os.Symlink("/nonexistent/secret", link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := resolvePath("broken_link", ws, true)
|
|
if err == nil {
|
|
t.Fatal("expected error for broken symlink outside workspace, got nil")
|
|
}
|
|
}
|
|
|
|
func TestResolvePath_DirSymlinkEscapeBlocked(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlinks require special privileges on Windows")
|
|
}
|
|
ws := setupWorkspace(t)
|
|
|
|
// Create a directory symlink pointing outside workspace
|
|
outside := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link := filepath.Join(ws, "evil_dir")
|
|
if err := os.Symlink(outside, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := resolvePath("evil_dir/secret.txt", ws, true)
|
|
if err == nil {
|
|
t.Fatal("expected error for directory symlink escape, got nil")
|
|
}
|
|
}
|
|
|
|
func TestResolvePath_NonExistentFileInWorkspace(t *testing.T) {
|
|
ws := setupWorkspace(t)
|
|
resolved, err := resolvePath("new_file.txt", ws, true)
|
|
if err != nil {
|
|
t.Fatalf("expected success for non-existent file in workspace, got: %v", err)
|
|
}
|
|
if filepath.Dir(resolved) == "" {
|
|
t.Fatal("expected resolved path to have directory")
|
|
}
|
|
}
|
|
|
|
func TestResolvePath_UnrestrictedAllowsEscape(t *testing.T) {
|
|
ws := setupWorkspace(t)
|
|
// restrict=false should allow any path
|
|
outside := filepath.Join(t.TempDir(), "hosts")
|
|
resolved, err := resolvePath(outside, ws, false)
|
|
if err != nil {
|
|
t.Fatalf("expected success with restrict=false, got: %v", err)
|
|
}
|
|
if resolved != filepath.Clean(outside) {
|
|
t.Fatalf("expected %s, got: %s", filepath.Clean(outside), resolved)
|
|
}
|
|
}
|
|
|
|
func TestCheckHardlink_NormalFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
f := filepath.Join(dir, "normal.txt")
|
|
if err := os.WriteFile(f, []byte("data"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := checkHardlink(f); err != nil {
|
|
t.Fatalf("expected no error for normal file, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCheckHardlink_HardlinkedFileBlocked(t *testing.T) {
|
|
dir := t.TempDir()
|
|
original := filepath.Join(dir, "original.txt")
|
|
if err := os.WriteFile(original, []byte("data"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hardlink := filepath.Join(dir, "hardlink.txt")
|
|
if err := os.Link(original, hardlink); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Both original and hardlink should be rejected (nlink=2)
|
|
if err := checkHardlink(original); err == nil {
|
|
t.Fatal("expected error for hardlinked file (original), got nil")
|
|
}
|
|
if err := checkHardlink(hardlink); err == nil {
|
|
t.Fatal("expected error for hardlinked file (link), got nil")
|
|
}
|
|
}
|
|
|
|
func TestCheckHardlink_DirectoryAllowed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Directories naturally have nlink > 1, should be exempt
|
|
if err := checkHardlink(dir); err != nil {
|
|
t.Fatalf("expected no error for directory, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCheckHardlink_NonExistent(t *testing.T) {
|
|
if err := checkHardlink("/nonexistent/path"); err != nil {
|
|
t.Fatalf("expected no error for non-existent file, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCheckDeniedPath(t *testing.T) {
|
|
ws := setupWorkspace(t)
|
|
wsReal, _ := filepath.EvalSymlinks(ws)
|
|
|
|
denied := filepath.Join(wsReal, ".goclaw", "secrets")
|
|
if err := os.MkdirAll(filepath.Dir(denied), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := checkDeniedPath(denied, ws, []string{".goclaw"})
|
|
if err == nil {
|
|
t.Fatal("expected error for denied path, got nil")
|
|
}
|
|
|
|
// Non-denied path should pass
|
|
err = checkDeniedPath(filepath.Join(wsReal, "hello.txt"), ws, []string{".goclaw"})
|
|
if err != nil {
|
|
t.Fatalf("expected no error for non-denied path, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResolvePathWithAllowed_TenantScoping(t *testing.T) {
|
|
// Simulate: tenant workspace is a subdirectory of global workspace.
|
|
// Paths outside tenant workspace but inside global should be BLOCKED.
|
|
globalWs := t.TempDir()
|
|
tenantWs := filepath.Join(globalWs, "tenants", "acme")
|
|
otherTenantWs := filepath.Join(globalWs, "tenants", "evil")
|
|
if err := os.MkdirAll(tenantWs, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(otherTenantWs, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(otherTenantWs, "secret.txt"), []byte("secret"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(tenantWs, "ok.txt"), []byte("ok"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Using tenant workspace as base: should allow files inside tenant workspace
|
|
_, err := resolvePathWithAllowed("ok.txt", tenantWs, true, nil)
|
|
if err != nil {
|
|
t.Fatalf("expected success for file in tenant workspace, got: %v", err)
|
|
}
|
|
|
|
// Using tenant workspace as base: should BLOCK files in other tenant's workspace
|
|
_, err = resolvePathWithAllowed(filepath.Join(otherTenantWs, "secret.txt"), tenantWs, true, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for path in another tenant's workspace, got nil")
|
|
}
|
|
|
|
// Using GLOBAL workspace as base (the bug): would wrongly allow cross-tenant access
|
|
_, err = resolvePathWithAllowed(filepath.Join(otherTenantWs, "secret.txt"), globalWs, true, nil)
|
|
if err != nil {
|
|
t.Fatal("global workspace allows all children (demonstrates why tenant scoping matters)")
|
|
}
|
|
}
|
|
|
|
func TestResolvePathWithAllowed_TeamWorkspaceAccess(t *testing.T) {
|
|
// Agent workspace and team workspace are separate directories.
|
|
// Team workspace should be accessible via allowed prefixes.
|
|
agentWs := t.TempDir()
|
|
teamWs := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(teamWs, "shared.txt"), []byte("shared"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Without team workspace in allowed: should BLOCK
|
|
_, err := resolvePathWithAllowed(filepath.Join(teamWs, "shared.txt"), agentWs, true, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error without team workspace in allowed prefixes, got nil")
|
|
}
|
|
|
|
// With team workspace in allowed: should ALLOW
|
|
_, err = resolvePathWithAllowed(filepath.Join(teamWs, "shared.txt"), agentWs, true, []string{teamWs})
|
|
if err != nil {
|
|
t.Fatalf("expected success with team workspace in allowed prefixes, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIsPathInside(t *testing.T) {
|
|
parent := filepath.Join(t.TempDir(), "a", "b")
|
|
child := filepath.Join(parent, "c")
|
|
sibling := parent + "c"
|
|
other := filepath.Join(t.TempDir(), "a", "b")
|
|
tests := []struct {
|
|
child, parent string
|
|
want bool
|
|
}{
|
|
{child, parent, true},
|
|
{parent, parent, true},
|
|
{sibling, parent, false}, // not a child, just prefix match
|
|
{filepath.Dir(parent), parent, false},
|
|
{other, parent, false},
|
|
}
|
|
for _, tt := range tests {
|
|
got := isPathInside(tt.child, tt.parent)
|
|
if got != tt.want {
|
|
t.Errorf("isPathInside(%q, %q) = %v, want %v", tt.child, tt.parent, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsPathInside_WindowsCaseInsensitive(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("Windows-specific test")
|
|
}
|
|
tests := []struct {
|
|
child, parent string
|
|
want bool
|
|
}{
|
|
{`C:\Workspace\file.txt`, `c:\workspace`, true}, // case mismatch
|
|
{`c:\workspace\file.txt`, `C:\Workspace`, true}, // reverse case
|
|
{`C:\WORKSPACE\SUB\FILE`, `c:\workspace`, true}, // all caps child
|
|
{`D:\other`, `C:\workspace`, false}, // different drive
|
|
{`C:\workspaceX\file.txt`, `C:\workspace`, false}, // prefix but not child
|
|
}
|
|
for _, tt := range tests {
|
|
got := isPathInside(tt.child, tt.parent)
|
|
if got != tt.want {
|
|
t.Errorf("isPathInside(%q, %q) = %v, want %v", tt.child, tt.parent, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolvePathWithAllowed_CrossDriveAccess(t *testing.T) {
|
|
// Simulates cross-drive access on Windows using separate temp directories
|
|
// (on Unix these are just separate paths, but the test logic is the same).
|
|
workspace := t.TempDir()
|
|
crossDrive := t.TempDir() // simulates a different drive (e.g., F:\ vs E:\)
|
|
|
|
// Create files in both locations
|
|
if err := os.WriteFile(filepath.Join(workspace, "local.txt"), []byte("local"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(crossDrive, "remote.txt"), []byte("remote"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Without allowed paths: cross-drive should be BLOCKED
|
|
_, err := resolvePathWithAllowed(filepath.Join(crossDrive, "remote.txt"), workspace, true, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for cross-drive access without allowed paths")
|
|
}
|
|
|
|
// With allowed paths: cross-drive should be ALLOWED
|
|
_, err = resolvePathWithAllowed(filepath.Join(crossDrive, "remote.txt"), workspace, true, []string{crossDrive})
|
|
if err != nil {
|
|
t.Fatalf("expected success with cross-drive in allowed paths, got: %v", err)
|
|
}
|
|
|
|
// Allowed paths should not allow escaping to arbitrary locations
|
|
outsideAll := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(outsideAll, "secret.txt"), []byte("secret"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = resolvePathWithAllowed(filepath.Join(outsideAll, "secret.txt"), workspace, true, []string{crossDrive})
|
|
if err == nil {
|
|
t.Fatal("expected error for path outside both workspace and allowed paths")
|
|
}
|
|
}
|
|
|
|
func TestResolvePathWithAllowed_TenantIsolation(t *testing.T) {
|
|
// Ensure that allowed paths cannot be used to escape tenant boundaries.
|
|
// Scenario: tenant A's workspace, tenant B's workspace, and a shared allowed path.
|
|
tenantA := t.TempDir()
|
|
tenantB := t.TempDir()
|
|
sharedSkills := t.TempDir()
|
|
|
|
// Create files
|
|
if err := os.WriteFile(filepath.Join(tenantA, "a.txt"), []byte("A"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(tenantB, "b.txt"), []byte("B"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sharedSkills, "skill.md"), []byte("skill"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Tenant A can access their workspace
|
|
_, err := resolvePathWithAllowed(filepath.Join(tenantA, "a.txt"), tenantA, true, []string{sharedSkills})
|
|
if err != nil {
|
|
t.Fatalf("tenant A should access own workspace: %v", err)
|
|
}
|
|
|
|
// Tenant A can access shared skills
|
|
_, err = resolvePathWithAllowed(filepath.Join(sharedSkills, "skill.md"), tenantA, true, []string{sharedSkills})
|
|
if err != nil {
|
|
t.Fatalf("tenant A should access shared skills: %v", err)
|
|
}
|
|
|
|
// Tenant A CANNOT access tenant B's workspace (even with shared skills allowed)
|
|
_, err = resolvePathWithAllowed(filepath.Join(tenantB, "b.txt"), tenantA, true, []string{sharedSkills})
|
|
if err == nil {
|
|
t.Fatal("tenant A should NOT access tenant B's workspace")
|
|
}
|
|
|
|
// Verify the error message is about access denied
|
|
if !strings.Contains(err.Error(), "access denied") {
|
|
t.Errorf("expected 'access denied' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResolvePathWithAllowed_SymlinkEscapeBlocked(t *testing.T) {
|
|
// Ensure symlinks in allowed paths cannot be used to escape boundaries.
|
|
workspace := t.TempDir()
|
|
allowedDir := t.TempDir()
|
|
secretDir := t.TempDir()
|
|
|
|
// Create a secret file outside both workspace and allowed
|
|
if err := os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("secret"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create a symlink in allowed dir pointing to secret dir
|
|
evilLink := filepath.Join(allowedDir, "escape")
|
|
if err := os.Symlink(secretDir, evilLink); err != nil {
|
|
t.Skip("cannot create symlinks (permissions or OS limitation)")
|
|
}
|
|
|
|
// Attempt to access secret file via symlink escape
|
|
_, err := resolvePathWithAllowed(filepath.Join(evilLink, "secret.txt"), workspace, true, []string{allowedDir})
|
|
if err == nil {
|
|
t.Fatal("expected error for symlink escape attempt")
|
|
}
|
|
}
|
|
|
|
func TestAllowedWithTeamWorkspace_TenantPathsMerge(t *testing.T) {
|
|
// Test that allowedWithTeamWorkspace correctly merges:
|
|
// base (global) + tenant paths (from context) + team workspace (from context)
|
|
ctx := context.Background()
|
|
|
|
base := []string{"/global/skills", "/global/builtin"}
|
|
tenantPaths := []string{"/tenant/allowed1", "/tenant/allowed2"}
|
|
teamWs := "/team/workspace"
|
|
|
|
// Inject tenant paths and team workspace into context
|
|
ctx = WithTenantAllowedPaths(ctx, tenantPaths)
|
|
ctx = WithToolTeamWorkspace(ctx, teamWs)
|
|
|
|
result := allowedWithTeamWorkspace(ctx, base)
|
|
|
|
// Verify all paths are present in correct order
|
|
expected := []string{"/global/skills", "/global/builtin", "/tenant/allowed1", "/tenant/allowed2", "/team/workspace"}
|
|
if len(result) != len(expected) {
|
|
t.Fatalf("expected %d paths, got %d", len(expected), len(result))
|
|
}
|
|
for i, exp := range expected {
|
|
if result[i] != exp {
|
|
t.Errorf("path[%d]: expected %q, got %q", i, exp, result[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAllowedWithTeamWorkspace_TenantPathsOnly(t *testing.T) {
|
|
// Test tenant paths without team workspace
|
|
ctx := context.Background()
|
|
base := []string{"/global/skills"}
|
|
tenantPaths := []string{"/tenant/data"}
|
|
|
|
ctx = WithTenantAllowedPaths(ctx, tenantPaths)
|
|
|
|
result := allowedWithTeamWorkspace(ctx, base)
|
|
|
|
if len(result) != 2 {
|
|
t.Fatalf("expected 2 paths, got %d", len(result))
|
|
}
|
|
if result[0] != "/global/skills" || result[1] != "/tenant/data" {
|
|
t.Errorf("unexpected result: %v", result)
|
|
}
|
|
}
|
|
|
|
func TestAllowedWithTeamWorkspace_TeamRootMerged(t *testing.T) {
|
|
// Team root should be appended after team workspace so leader/member agents
|
|
// can read peer-scoped files in the same team without enabling shared mode.
|
|
ctx := context.Background()
|
|
base := []string{"/global/skills"}
|
|
teamWs := "/data/teams/abc/chatA"
|
|
teamRoot := "/data/teams/abc"
|
|
|
|
ctx = WithToolTeamWorkspace(ctx, teamWs)
|
|
ctx = WithToolTeamRoot(ctx, teamRoot)
|
|
|
|
result := allowedWithTeamWorkspace(ctx, base)
|
|
|
|
expected := []string{"/global/skills", teamWs, teamRoot}
|
|
if len(result) != len(expected) {
|
|
t.Fatalf("expected %d paths, got %d: %v", len(expected), len(result), result)
|
|
}
|
|
for i, exp := range expected {
|
|
if result[i] != exp {
|
|
t.Errorf("path[%d]: expected %q, got %q", i, exp, result[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAllowedWriteWithTeamWorkspace_ExcludesTeamRoot(t *testing.T) {
|
|
// Write variant must NOT include team root — cross-chat writes are blocked
|
|
// even when reads across the same team are allowed. Shared-mode parity:
|
|
// when teamWs == teamRoot (shared workspace), writing to teamWs is still
|
|
// permitted because teamWs is the leaf scope.
|
|
ctx := context.Background()
|
|
base := []string{"/global/skills"}
|
|
teamWs := "/data/teams/abc/chatA"
|
|
teamRoot := "/data/teams/abc"
|
|
|
|
ctx = WithToolTeamWorkspace(ctx, teamWs)
|
|
ctx = WithToolTeamRoot(ctx, teamRoot)
|
|
|
|
writeAllowed := allowedWriteWithTeamWorkspace(ctx, base)
|
|
readAllowed := allowedWithTeamWorkspace(ctx, base)
|
|
|
|
// Read allowed should include team root.
|
|
if len(readAllowed) != 3 {
|
|
t.Fatalf("read: expected 3 prefixes, got %d: %v", len(readAllowed), readAllowed)
|
|
}
|
|
// Write allowed must NOT include team root.
|
|
if len(writeAllowed) != 2 {
|
|
t.Fatalf("write: expected 2 prefixes (base + teamWs), got %d: %v", len(writeAllowed), writeAllowed)
|
|
}
|
|
for _, p := range writeAllowed {
|
|
if p == teamRoot {
|
|
t.Errorf("write allowed must not include team root %q", teamRoot)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAllowedWithTeamWorkspace_TeamRootDeduped(t *testing.T) {
|
|
// When team root == team workspace (shared-workspace mode), avoid duplicate entry.
|
|
ctx := context.Background()
|
|
base := []string{"/global/skills"}
|
|
same := "/data/teams/abc"
|
|
|
|
ctx = WithToolTeamWorkspace(ctx, same)
|
|
ctx = WithToolTeamRoot(ctx, same)
|
|
|
|
result := allowedWithTeamWorkspace(ctx, base)
|
|
|
|
if len(result) != 2 {
|
|
t.Fatalf("expected 2 paths (base + one team path), got %d: %v", len(result), result)
|
|
}
|
|
if result[0] != "/global/skills" || result[1] != same {
|
|
t.Errorf("unexpected result: %v", result)
|
|
}
|
|
}
|
|
|
|
func TestResolvePathWithAllowed_TeamRootCrossChatAccess(t *testing.T) {
|
|
// Reproduces trace 019db4df-c2e2: leader agent in chat scope "chatA" tries to
|
|
// read a file generated by a teammate under chat scope "chatB" within the
|
|
// same team. Team root as allowed prefix must permit this cross-chat read.
|
|
teamRoot := t.TempDir()
|
|
chatA := filepath.Join(teamRoot, "chatA")
|
|
chatB := filepath.Join(teamRoot, "chatB", "generated")
|
|
if err := os.MkdirAll(chatA, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(chatB, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
peerFile := filepath.Join(chatB, "v3-02-prompt-mode.png")
|
|
if err := os.WriteFile(peerFile, []byte("img"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Without team root: leader scoped to chatA cannot reach chatB.
|
|
_, err := resolvePathWithAllowed(peerFile, chatA, true, nil)
|
|
if err == nil {
|
|
t.Fatal("expected error without team root in allowed prefixes, got nil")
|
|
}
|
|
|
|
// With team root as allowed prefix: access granted.
|
|
_, err = resolvePathWithAllowed(peerFile, chatA, true, []string{teamRoot})
|
|
if err != nil {
|
|
t.Fatalf("expected success with team root in allowed prefixes, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAllowedWithTeamWorkspace_EmptyContext(t *testing.T) {
|
|
// Test with no tenant paths or team workspace in context
|
|
ctx := context.Background()
|
|
base := []string{"/global/skills"}
|
|
|
|
result := allowedWithTeamWorkspace(ctx, base)
|
|
|
|
// Should return base unchanged
|
|
if len(result) != 1 || result[0] != "/global/skills" {
|
|
t.Errorf("expected base unchanged, got: %v", result)
|
|
}
|
|
}
|
|
|
|
func TestTenantAllowedPathsFromCtx_Inheritance(t *testing.T) {
|
|
// Test that TenantAllowedPathsFromCtx correctly reads from RunContext fallback
|
|
ctx := context.Background()
|
|
tenantPaths := []string{"/tenant/path1", "/tenant/path2"}
|
|
|
|
// Set via direct context
|
|
ctx = WithTenantAllowedPaths(ctx, tenantPaths)
|
|
result := TenantAllowedPathsFromCtx(ctx)
|
|
|
|
if len(result) != 2 {
|
|
t.Fatalf("expected 2 paths, got %d", len(result))
|
|
}
|
|
if result[0] != "/tenant/path1" || result[1] != "/tenant/path2" {
|
|
t.Errorf("unexpected result: %v", result)
|
|
}
|
|
}
|