Skip to content

Commit 3bb09fb

Browse files
authored
add directory scope enforcement to block out-of-scope file access
- Add allowed_directories policy that prevents LLM tools from sending - files outside configured project directories. Resolves relative paths and ../traversals. Includes desktop UI support and 6 new test cases.
1 parent b0f4a00 commit 3bb09fb

10 files changed

Lines changed: 383 additions & 42 deletions

File tree

config.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
listen_address: "127.0.0.1:8080"
22

33
policy:
4+
# Block requests that reference files outside these directories.
5+
# If empty, no directory scope is enforced.
6+
allowed_directories:
7+
# - "~/Projects/my-app"
8+
49
deny_file_patterns:
510
- "*.env"
611
- "*.pem"

internal/config/config.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ type InterceptConfig struct {
2323
}
2424

2525
type PolicyConfig struct {
26-
DenyFilePatterns []string `yaml:"deny_file_patterns"`
26+
DenyFilePatterns []string `yaml:"deny_file_patterns"`
27+
AllowedDirectories []string `yaml:"allowed_directories"`
2728
}
2829

2930
type LogConfig struct {
@@ -62,6 +63,11 @@ func Load(path string) (*Config, error) {
6263
cfg.Intercept.CACert = expandHome(cfg.Intercept.CACert)
6364
cfg.Intercept.CAKey = expandHome(cfg.Intercept.CAKey)
6465

66+
// Expand allowed directories
67+
for i, dir := range cfg.Policy.AllowedDirectories {
68+
cfg.Policy.AllowedDirectories[i] = expandHome(dir)
69+
}
70+
6571
// Apply logging defaults
6672
if cfg.Logging.File == "" {
6773
cfg.Logging.File = defaultLogPath()

internal/policy/policy.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package policy
22

33
import (
44
"fmt"
5+
"os"
56
"path/filepath"
67
"strings"
78
"sync"
@@ -30,6 +31,13 @@ func (e *Engine) IsBypassed() bool {
3031
}
3132

3233
func NewEngine(cfg config.PolicyConfig) *Engine {
34+
// Clean and resolve allowed directories at construction time
35+
dirs := make([]string, 0, len(cfg.AllowedDirectories))
36+
for _, d := range cfg.AllowedDirectories {
37+
cleaned := filepath.Clean(d)
38+
dirs = append(dirs, cleaned)
39+
}
40+
cfg.AllowedDirectories = dirs
3341
return &Engine{cfg: cfg}
3442
}
3543

@@ -70,6 +78,81 @@ func (e *Engine) RemoveDenyPattern(pattern string) {
7078
e.cfg.DenyFilePatterns = filtered
7179
}
7280

81+
// GetAllowedDirectories returns the current allowed directories.
82+
func (e *Engine) GetAllowedDirectories() []string {
83+
e.mu.RLock()
84+
defer e.mu.RUnlock()
85+
out := make([]string, len(e.cfg.AllowedDirectories))
86+
copy(out, e.cfg.AllowedDirectories)
87+
return out
88+
}
89+
90+
// SetAllowedDirectories replaces the allowed directories list.
91+
func (e *Engine) SetAllowedDirectories(dirs []string) {
92+
e.mu.Lock()
93+
defer e.mu.Unlock()
94+
cleaned := make([]string, len(dirs))
95+
for i, d := range dirs {
96+
cleaned[i] = filepath.Clean(d)
97+
}
98+
e.cfg.AllowedDirectories = cleaned
99+
}
100+
101+
// EvaluateScope checks if any detected file paths fall outside the allowed directories.
102+
// If no allowed directories are configured, all paths are allowed.
103+
func (e *Engine) EvaluateScope(paths []string) Decision {
104+
if e.bypassed.Load() {
105+
return Decision{Allowed: true, Reason: "policy bypassed (paused)"}
106+
}
107+
108+
e.mu.RLock()
109+
allowedDirs := e.cfg.AllowedDirectories
110+
e.mu.RUnlock()
111+
112+
if len(allowedDirs) == 0 {
113+
return Decision{Allowed: true, Reason: "no directory scope configured"}
114+
}
115+
116+
for _, p := range paths {
117+
if !isInScope(p, allowedDirs) {
118+
return Decision{
119+
Allowed: false,
120+
Reason: fmt.Sprintf("file %q is outside allowed directories", p),
121+
}
122+
}
123+
}
124+
return Decision{Allowed: true, Reason: "all files within allowed directories"}
125+
}
126+
127+
// isInScope checks whether a file path falls within any of the allowed directories.
128+
func isInScope(filePath string, allowedDirs []string) bool {
129+
// Resolve the path to absolute for comparison
130+
resolved := resolvePath(filePath)
131+
132+
for _, dir := range allowedDirs {
133+
// A file is in scope if its resolved path starts with the allowed dir + separator
134+
dirWithSep := dir + string(filepath.Separator)
135+
if resolved == dir || strings.HasPrefix(resolved, dirWithSep) {
136+
return true
137+
}
138+
}
139+
return false
140+
}
141+
142+
// resolvePath attempts to resolve a file path to an absolute, cleaned path.
143+
// For relative paths, it resolves against the current working directory.
144+
func resolvePath(p string) string {
145+
p = filepath.Clean(p)
146+
if filepath.IsAbs(p) {
147+
return p
148+
}
149+
// Resolve relative paths against cwd
150+
if cwd, err := os.Getwd(); err == nil {
151+
return filepath.Join(cwd, p)
152+
}
153+
return p
154+
}
155+
73156
// EvaluateFiles checks if any detected file paths match deny_file_patterns.
74157
func (e *Engine) EvaluateFiles(paths []string) Decision {
75158
if e.bypassed.Load() {

internal/policy/policy_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,95 @@ func TestEvaluateFiles_Bypassed(t *testing.T) {
6868
}
6969
}
7070

71+
func TestEvaluateScope_InScope(t *testing.T) {
72+
engine := NewEngine(config.PolicyConfig{
73+
AllowedDirectories: []string{"/home/user/project"},
74+
})
75+
76+
tests := []struct {
77+
name string
78+
paths []string
79+
allowed bool
80+
}{
81+
{"file in project", []string{"/home/user/project/main.go"}, true},
82+
{"nested file in project", []string{"/home/user/project/src/app.go"}, true},
83+
{"file outside project", []string{"/etc/passwd"}, false},
84+
{"home dir file", []string{"/home/user/.ssh/id_rsa"}, false},
85+
{"sibling project", []string{"/home/user/other-project/main.go"}, false},
86+
{"parent traversal", []string{"/home/user/project/../.ssh/id_rsa"}, false},
87+
{"mix in and out of scope", []string{"/home/user/project/main.go", "/etc/passwd"}, false},
88+
{"empty list", []string{}, true},
89+
{"exact dir match", []string{"/home/user/project"}, true},
90+
}
91+
92+
for _, tt := range tests {
93+
t.Run(tt.name, func(t *testing.T) {
94+
decision := engine.EvaluateScope(tt.paths)
95+
if decision.Allowed != tt.allowed {
96+
t.Errorf("EvaluateScope(%v) = allowed:%v, want allowed:%v (reason: %s)",
97+
tt.paths, decision.Allowed, tt.allowed, decision.Reason)
98+
}
99+
})
100+
}
101+
}
102+
103+
func TestEvaluateScope_MultipleAllowedDirs(t *testing.T) {
104+
engine := NewEngine(config.PolicyConfig{
105+
AllowedDirectories: []string{"/home/user/project-a", "/home/user/project-b"},
106+
})
107+
108+
tests := []struct {
109+
name string
110+
paths []string
111+
allowed bool
112+
}{
113+
{"file in project-a", []string{"/home/user/project-a/main.go"}, true},
114+
{"file in project-b", []string{"/home/user/project-b/main.go"}, true},
115+
{"file in neither", []string{"/home/user/project-c/main.go"}, false},
116+
}
117+
118+
for _, tt := range tests {
119+
t.Run(tt.name, func(t *testing.T) {
120+
decision := engine.EvaluateScope(tt.paths)
121+
if decision.Allowed != tt.allowed {
122+
t.Errorf("EvaluateScope(%v) = allowed:%v, want allowed:%v (reason: %s)",
123+
tt.paths, decision.Allowed, tt.allowed, decision.Reason)
124+
}
125+
})
126+
}
127+
}
128+
129+
func TestEvaluateScope_NoDirectoriesConfigured(t *testing.T) {
130+
engine := NewEngine(config.PolicyConfig{})
131+
decision := engine.EvaluateScope([]string{"/anywhere/file.go"})
132+
if !decision.Allowed {
133+
t.Error("expected allowed when no directories configured")
134+
}
135+
}
136+
137+
func TestEvaluateScope_Bypassed(t *testing.T) {
138+
engine := NewEngine(config.PolicyConfig{
139+
AllowedDirectories: []string{"/home/user/project"},
140+
})
141+
engine.SetBypassed(true)
142+
decision := engine.EvaluateScope([]string{"/etc/passwd"})
143+
if !decision.Allowed {
144+
t.Error("expected allowed when policy bypassed")
145+
}
146+
}
147+
148+
func TestEvaluateScope_ParentTraversal(t *testing.T) {
149+
engine := NewEngine(config.PolicyConfig{
150+
AllowedDirectories: []string{"/home/user/project"},
151+
})
152+
153+
// ../.. traversal should be caught after path cleaning
154+
decision := engine.EvaluateScope([]string{"/home/user/project/../../etc/passwd"})
155+
if decision.Allowed {
156+
t.Error("expected blocked for parent traversal escaping allowed directory")
157+
}
158+
}
159+
71160
func TestMatchFilePattern(t *testing.T) {
72161
tests := []struct {
73162
path string

internal/proxy/intercept.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,39 @@ func (i *Interceptor) Intercept(clientConn net.Conn, upstreamConn net.Conn, host
105105
"files", len(files),
106106
)
107107

108-
// Check file policy — block if denied
109108
paths := make([]string, len(files))
110109
for idx, f := range files {
111110
paths[idx] = f.Path
112111
}
113-
decision := i.policy.EvaluateFiles(paths)
112+
113+
// Check directory scope — block if any file is outside allowed directories
114+
decision := i.policy.EvaluateScope(paths)
115+
if !decision.Allowed {
116+
slog.Warn("request blocked by directory scope policy",
117+
"session", sess.ID,
118+
"url", exchange.URL,
119+
"reason", decision.Reason,
120+
)
121+
exchange.StatusCode = 403
122+
exchange.Blocked = true
123+
exchange.BlockReason = decision.Reason
124+
if i.logBody {
125+
exchange.RequestBody = truncateBody(bodyStr, i.maxBody)
126+
}
127+
resp403 := &http.Response{
128+
StatusCode: 403,
129+
ProtoMajor: 1,
130+
ProtoMinor: 1,
131+
Header: http.Header{"Content-Type": {"text/plain"}},
132+
Body: io.NopCloser(strings.NewReader("blocked by egressor: " + decision.Reason)),
133+
}
134+
resp403.Write(clientTLS)
135+
sess.Exchanges = append(sess.Exchanges, exchange)
136+
return nil
137+
}
138+
139+
// Check file deny patterns — block if matched
140+
decision = i.policy.EvaluateFiles(paths)
114141
if !decision.Allowed {
115142
slog.Warn("request blocked by file policy",
116143
"session", sess.ID,

internal/ui/app.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,31 @@ func (a *App) RemoveDenyPattern(pattern string) {
8484
a.engine.RemoveDenyPattern(pattern)
8585
}
8686

87+
func (a *App) GetAllowedDirectories() []string {
88+
return a.engine.GetAllowedDirectories()
89+
}
90+
91+
func (a *App) SetAllowedDirectories(dirs []string) {
92+
a.engine.SetAllowedDirectories(dirs)
93+
}
94+
95+
func (a *App) AddAllowedDirectory(dir string) {
96+
dirs := a.engine.GetAllowedDirectories()
97+
dirs = append(dirs, dir)
98+
a.engine.SetAllowedDirectories(dirs)
99+
}
100+
101+
func (a *App) RemoveAllowedDirectory(dir string) {
102+
dirs := a.engine.GetAllowedDirectories()
103+
filtered := dirs[:0]
104+
for _, d := range dirs {
105+
if d != dir {
106+
filtered = append(filtered, d)
107+
}
108+
}
109+
a.engine.SetAllowedDirectories(filtered)
110+
}
111+
87112
func (a *App) IsPolicyBypassed() bool {
88113
return a.engine.IsBypassed()
89114
}
@@ -99,6 +124,7 @@ func (a *App) SetPolicyBypassed(bypassed bool) {
99124

100125
func (a *App) SaveConfig() error {
101126
a.cfg.Policy.DenyFilePatterns = a.engine.GetDenyPatterns()
127+
a.cfg.Policy.AllowedDirectories = a.engine.GetAllowedDirectories()
102128
return config.Save(a.cfgPath, a.cfg)
103129
}
104130

internal/ui/frontend/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ function App() {
7777
patterns={policy.patterns}
7878
onAdd={policy.addPattern}
7979
onRemove={policy.removePattern}
80+
allowedDirs={policy.allowedDirs}
81+
onAddDir={policy.addDirectory}
82+
onRemoveDir={policy.removeDirectory}
8083
onSave={policy.save}
8184
/>
8285
</div>

0 commit comments

Comments
 (0)