Skip to content

Commit 8b643ca

Browse files
feat(tools): scope file reads to workspace
1 parent aa355cf commit 8b643ca

8 files changed

Lines changed: 230 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ vikusha create writer
6969
vikusha chat writer
7070
```
7171

72+
Named agents get their own `workspace/`. Built-in file tools read from that workspace and reject paths outside it.
73+
7274
You can load the same YAML from Go.
7375

7476
```go

cmd/vikusha/main.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,17 @@ func buildAgent(input string) (*agent.Agent, error) {
184184
if err != nil {
185185
return nil, err
186186
}
187-
return vikusha.LoadAgent(path, vikusha.Options{})
187+
return vikusha.LoadAgent(path, vikusha.Options{
188+
Workspace: workspaceForCharacter(path),
189+
})
190+
}
191+
192+
func workspaceForCharacter(path string) string {
193+
workspace := filepath.Join(filepath.Dir(path), "workspace")
194+
if fileExists(workspace) {
195+
return workspace
196+
}
197+
return ""
188198
}
189199

190200
func resolveCharacterPath(input string) (string, error) {

cmd/vikusha/main_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,23 @@ func TestResolveCharacterPathReportsMissingNamedAgent(t *testing.T) {
160160
t.Fatalf("expected agent path in error, got %q", err.Error())
161161
}
162162
}
163+
164+
func TestWorkspaceForCharacterUsesSiblingWorkspace(t *testing.T) {
165+
dir := t.TempDir()
166+
workspace := filepath.Join(dir, "workspace")
167+
if err := os.Mkdir(workspace, 0o700); err != nil {
168+
t.Fatal(err)
169+
}
170+
171+
got := workspaceForCharacter(filepath.Join(dir, "character.yaml"))
172+
if got != workspace {
173+
t.Fatalf("workspaceForCharacter() = %q, want %q", got, workspace)
174+
}
175+
}
176+
177+
func TestWorkspaceForCharacterReturnsEmptyWhenMissing(t *testing.T) {
178+
got := workspaceForCharacter(filepath.Join(t.TempDir(), "character.yaml"))
179+
if got != "" {
180+
t.Fatalf("workspaceForCharacter() = %q, want empty", got)
181+
}
182+
}

core/tools/file/read.go

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,21 @@ import (
55
"encoding/json"
66
"fmt"
77
"os"
8+
"path/filepath"
9+
"strings"
810
)
911

10-
type Read struct{}
12+
type Read struct {
13+
root string
14+
}
1115

12-
func NewRead() *Read { return &Read{} }
16+
func NewRead(root ...string) *Read {
17+
r := &Read{}
18+
if len(root) > 0 {
19+
r.root = strings.TrimSpace(root[0])
20+
}
21+
return r
22+
}
1323

1424
func (r *Read) Name() string { return "file_read" }
1525

@@ -42,9 +52,45 @@ func (r *Read) Run(ctx context.Context, input json.RawMessage) (string, error) {
4252
if in.Path == "" {
4353
return "", fmt.Errorf("invalid input: path is required")
4454
}
45-
data, err := os.ReadFile(in.Path)
55+
path, err := r.resolve(in.Path)
56+
if err != nil {
57+
return "", err
58+
}
59+
data, err := os.ReadFile(path)
4660
if err != nil {
4761
return "", err
4862
}
4963
return string(data), nil
5064
}
65+
66+
func (r *Read) resolve(path string) (string, error) {
67+
if r.root == "" {
68+
return path, nil
69+
}
70+
71+
root, err := filepath.Abs(r.root)
72+
if err != nil {
73+
return "", err
74+
}
75+
root, err = filepath.EvalSymlinks(root)
76+
if err != nil {
77+
return "", err
78+
}
79+
80+
target := path
81+
if !filepath.IsAbs(target) {
82+
target = filepath.Join(root, target)
83+
}
84+
target, err = filepath.Abs(target)
85+
if err != nil {
86+
return "", err
87+
}
88+
target, err = filepath.EvalSymlinks(target)
89+
if err != nil {
90+
return "", err
91+
}
92+
if target != root && !strings.HasPrefix(target, root+string(os.PathSeparator)) {
93+
return "", fmt.Errorf("path %q is outside workspace %q", path, root)
94+
}
95+
return target, nil
96+
}

core/tools/file/read_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package file
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestReadUnscopedReadsPath(t *testing.T) {
13+
path := filepath.Join(t.TempDir(), "note.txt")
14+
if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil {
15+
t.Fatal(err)
16+
}
17+
18+
got, err := NewRead().Run(context.Background(), readJSON(path))
19+
if err != nil {
20+
t.Fatal(err)
21+
}
22+
if got != "hello" {
23+
t.Fatalf("Run() = %q, want hello", got)
24+
}
25+
}
26+
27+
func TestReadScopedReadsRelativePathFromWorkspace(t *testing.T) {
28+
workspace := t.TempDir()
29+
if err := os.WriteFile(filepath.Join(workspace, "note.txt"), []byte("hello"), 0o600); err != nil {
30+
t.Fatal(err)
31+
}
32+
33+
got, err := NewRead(workspace).Run(context.Background(), readJSON("note.txt"))
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
if got != "hello" {
38+
t.Fatalf("Run() = %q, want hello", got)
39+
}
40+
}
41+
42+
func TestReadScopedRejectsParentEscape(t *testing.T) {
43+
base := t.TempDir()
44+
workspace := filepath.Join(base, "workspace")
45+
if err := os.Mkdir(workspace, 0o700); err != nil {
46+
t.Fatal(err)
47+
}
48+
if err := os.WriteFile(filepath.Join(base, "secret.txt"), []byte("secret"), 0o600); err != nil {
49+
t.Fatal(err)
50+
}
51+
52+
_, err := NewRead(workspace).Run(context.Background(), readJSON("../secret.txt"))
53+
if err == nil {
54+
t.Fatal("expected workspace escape error")
55+
}
56+
if !strings.Contains(err.Error(), "outside workspace") {
57+
t.Fatalf("error = %q, want outside workspace", err)
58+
}
59+
}
60+
61+
func TestReadScopedRejectsAbsolutePathOutsideWorkspace(t *testing.T) {
62+
workspace := t.TempDir()
63+
outside := filepath.Join(t.TempDir(), "secret.txt")
64+
if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil {
65+
t.Fatal(err)
66+
}
67+
68+
_, err := NewRead(workspace).Run(context.Background(), readJSON(outside))
69+
if err == nil {
70+
t.Fatal("expected workspace escape error")
71+
}
72+
}
73+
74+
func TestReadScopedRejectsSymlinkEscape(t *testing.T) {
75+
base := t.TempDir()
76+
workspace := filepath.Join(base, "workspace")
77+
if err := os.Mkdir(workspace, 0o700); err != nil {
78+
t.Fatal(err)
79+
}
80+
secret := filepath.Join(base, "secret.txt")
81+
if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil {
82+
t.Fatal(err)
83+
}
84+
if err := os.Symlink(secret, filepath.Join(workspace, "secret-link")); err != nil {
85+
t.Fatal(err)
86+
}
87+
88+
_, err := NewRead(workspace).Run(context.Background(), readJSON("secret-link"))
89+
if err == nil {
90+
t.Fatal("expected workspace escape error")
91+
}
92+
}
93+
94+
func readJSON(path string) json.RawMessage {
95+
data, _ := json.Marshal(readInput{Path: path})
96+
return data
97+
}

docs/ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ A single agent you can talk to from the terminal, backed by a core harness that
6464

6565
- [x] Each agent owns `~/.vikusha/agents/<name>/` with its own character, memory directory, and workspace directory.
6666
- [ ] Per-agent logs under `~/.vikusha/agents/<name>/logs`.
67-
- [ ] File tools default-scoped to the agent's workspace.
67+
- [x] File tools default-scoped to the agent's workspace.
6868
- [ ] Paths outside the workspace require explicit approval, persisted per agent.
69-
- [ ] Path resolution blocks `..` escapes and symlinks pointing outside the workspace.
69+
- [x] Path resolution blocks `..` escapes and symlinks pointing outside the workspace.
7070

7171
### Tools
7272

vikusha.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import (
1515
)
1616

1717
type Options struct {
18-
Env func(string) string
19-
Tools map[string]tool.Tool
18+
Env func(string) string
19+
Tools map[string]tool.Tool
20+
Workspace string
2021
}
2122

2223
func LoadAgent(path string, opts Options) (*agent.Agent, error) {
@@ -93,7 +94,7 @@ func buildMemory(c *character.Character) (memory.Memory, error) {
9394
func registry(names []string, opts Options) (*tool.Registry, error) {
9495
reg := tool.NewRegistry()
9596
available := map[string]tool.Tool{
96-
"file_read": file.NewRead(),
97+
"file_read": file.NewRead(opts.Workspace),
9798
}
9899
for name, t := range opts.Tools {
99100
available[name] = t

vikusha_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package vikusha
22

33
import (
4+
"context"
5+
"encoding/json"
46
"os"
57
"path/filepath"
68
"strings"
@@ -62,3 +64,46 @@ memory:
6264
t.Fatalf("error = %q, want memory.path", err)
6365
}
6466
}
67+
68+
func TestRegistryScopesBuiltInFileReadToWorkspace(t *testing.T) {
69+
base := t.TempDir()
70+
workspace := filepath.Join(base, "workspace")
71+
if err := os.Mkdir(workspace, 0o700); err != nil {
72+
t.Fatal(err)
73+
}
74+
if err := os.WriteFile(filepath.Join(workspace, "note.txt"), []byte("inside"), 0o600); err != nil {
75+
t.Fatal(err)
76+
}
77+
if err := os.WriteFile(filepath.Join(base, "secret.txt"), []byte("outside"), 0o600); err != nil {
78+
t.Fatal(err)
79+
}
80+
81+
reg, err := registry([]string{"file_read"}, Options{Workspace: workspace})
82+
if err != nil {
83+
t.Fatal(err)
84+
}
85+
read, ok := reg.Get("file_read")
86+
if !ok {
87+
t.Fatal("file_read not registered")
88+
}
89+
90+
got, err := read.Run(context.Background(), mustJSON(t, map[string]string{"path": "note.txt"}))
91+
if err != nil {
92+
t.Fatal(err)
93+
}
94+
if got != "inside" {
95+
t.Fatalf("file_read = %q, want inside", got)
96+
}
97+
if _, err := read.Run(context.Background(), mustJSON(t, map[string]string{"path": "../secret.txt"})); err == nil {
98+
t.Fatal("expected workspace escape error")
99+
}
100+
}
101+
102+
func mustJSON(t *testing.T, v any) json.RawMessage {
103+
t.Helper()
104+
data, err := json.Marshal(v)
105+
if err != nil {
106+
t.Fatal(err)
107+
}
108+
return data
109+
}

0 commit comments

Comments
 (0)