Skip to content

Commit 207e5da

Browse files
authored
feat: implement filesystem path validation in policy engine (#4)
* feat: implement filesystem path validation in policy engine * security: hardening filesystem policy with symlink resolution and precise segment matching
1 parent 4b6a523 commit 207e5da

3 files changed

Lines changed: 249 additions & 0 deletions

File tree

ROADMAP.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Sandforge Implementation Roadmap
2+
3+
This roadmap tracks the progress of the Sandforge Agent Sandbox based on [ARCHITECTURE.md](ARCHITECTURE.md).
4+
5+
## Phase 1: Foundation & Policy (Security First)
6+
*Goal: Establish the core interfaces and the "Deny by Default" security layer.*
7+
8+
- [x] **1.1 Project Scaffolding**: Go workspace, directory structure, and `go.mod`.
9+
- [x] **1.2 Core API Contracts**: Define `SandboxSpec`, `ExecRequest`, and `SandboxBackend` interfaces.
10+
- [ ] **1.3 Policy Engine**:
11+
- [x] Filesystem path validation (whitelist logic) [#1](https://github.com/yanurag-dev/sandforge/issues/1).
12+
- [ ] Network mode enforcement (Offline/Fetch/Full) [#2](https://github.com/yanurag-dev/sandforge/issues/2).
13+
- [ ] Resource limit validation (CPU/Memory/Disk) [#2](https://github.com/yanurag-dev/sandforge/issues/2).
14+
- [ ] Command family filtering [#3](https://github.com/yanurag-dev/sandforge/issues/3).
15+
- [ ] **1.4 Testing**: Unit tests for policy enforcement.
16+
17+
## Phase 2: Orchestration & Mocking
18+
*Goal: Build the state machine that manages sandbox lifecycles.*
19+
20+
- [ ] **2.1 Sandbox Supervisor**:
21+
- [ ] Implementation of the Lifecycle State Machine (Requested -> Provisioning -> Ready -> ...).
22+
- [ ] Concurrent session management.
23+
- [ ] **2.2 Mock Backend Driver**:
24+
- [ ] An in-memory/process-based driver for testing the supervisor without a VM.
25+
- [ ] **2.3 Artifact Manager**: Basic logic to handle "CopyOut" for logs and files.
26+
27+
## Phase 3: macOS Execution Plane (macos-vz)
28+
*Goal: Boot a real Linux VM on macOS using the Apple Virtualization Framework.*
29+
30+
- [ ] **3.1 Worker Image Preparation**: Create a minimal Linux kernel + initrd/disk image.
31+
- [ ] **3.2 VZ Driver Implementation**:
32+
- [ ] VM configuration (vCPU, Memory).
33+
- [ ] Virtio-fs or Virtio-9p for workspace mounting.
34+
- [ ] Virtio-serial or VSOCK for command transport.
35+
- [ ] **3.3 Networking**: Implement `offline` and `fetch` (NAT) modes using VZ.
36+
37+
## Phase 4: Linux Execution Plane (linux-kvm)
38+
*Goal: Parity for Linux hosts.*
39+
40+
- [ ] **4.1 KVM/QEMU Driver**:
41+
- [ ] Implementation of the `SandboxBackend` using KVM.
42+
- [ ] Shared filesystem setup (Virtio-fs).
43+
- [ ] **4.2 (Optional) Firecracker**: MicroVM support for ultra-fast boot.
44+
45+
## Phase 5: Task Runtime (Inside the Worker)
46+
*Goal: The boundary between the VM and the Agent's code.*
47+
48+
- [ ] **5.1 Rootless Container Setup**: Pre-installing and configuring a container runtime (e.g., Podman/Docker) in the worker image.
49+
- [ ] **5.2 Task Runner Agent**: A small Go binary inside the VM that receives commands via VSOCK and runs them in a container.
50+
- [ ] **5.3 Cleanup Logic**: Ensuring the task container is destroyed immediately after execution.
51+
52+
## Phase 6: Control Plane & Adapters
53+
*Goal: The external interface for Coding Agents.*
54+
55+
- [ ] **6.1 Control Plane API**: REST/gRPC server to manage tasks and sessions.
56+
- [ ] **6.2 Agent Adapters**:
57+
- [ ] Generic Tool-Calling Adapter.
58+
- [ ] (Optional) Specific adapters for Claude/Codex.
59+
- [ ] **6.3 Secret Manager**: Injection of scoped secrets into the task environment.
60+
61+
## Phase 7: CLI & Experience
62+
*Goal: Making it usable.*
63+
64+
- [ ] **7.1 Sandforge CLI**: Commands like `sandforge run --dir . "npm test"`.
65+
- [ ] **7.2 Logging & Streaming**: Real-time stdout/stderr streaming from the sandbox to the terminal.
66+
- [ ] **7.3 Audit Logs**: Persisting execution history for review.
67+
68+
---
69+
## Progress Legend
70+
- [ ] To Do
71+
- [x] Done

internal/policy/engine.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package policy
2+
3+
import (
4+
"errors"
5+
"path/filepath"
6+
"strings"
7+
8+
"github.com/sandforge/sandforge/pkg/api"
9+
)
10+
11+
var (
12+
ErrForbiddenHostPath = errors.New("requested host path is forbidden by policy")
13+
ErrPathNotAbs = errors.New("host path must be an absolute path")
14+
)
15+
16+
type Engine struct {
17+
AllowedHostPrefixes []string
18+
BlockedHostPatterns []string
19+
}
20+
21+
func (e *Engine) EvaluateMount(mount api.WorkspaceMount) error {
22+
path := filepath.Clean(mount.HostPath)
23+
24+
if !filepath.IsAbs(path) {
25+
return ErrPathNotAbs
26+
}
27+
28+
// Resolve symlinks to prevent bypasses (e.g., a symlink pointing to /etc)
29+
resolved, err := filepath.EvalSymlinks(path)
30+
if err != nil {
31+
return err // Path must exist to be validated
32+
}
33+
path = resolved
34+
35+
allowed := false
36+
for _, prefix := range e.AllowedHostPrefixes {
37+
p, err := filepath.EvalSymlinks(filepath.Clean(prefix))
38+
if err != nil {
39+
continue // Skip invalid prefixes
40+
}
41+
if path == p || strings.HasPrefix(path, p+string(filepath.Separator)) {
42+
allowed = true
43+
break
44+
}
45+
}
46+
47+
if !allowed {
48+
return ErrForbiddenHostPath
49+
}
50+
51+
// Precise segment matching for blocklist to avoid false positives
52+
segments := strings.Split(path, string(filepath.Separator))
53+
for _, pattern := range e.BlockedHostPatterns {
54+
for _, segment := range segments {
55+
if segment == pattern {
56+
return ErrForbiddenHostPath
57+
}
58+
}
59+
}
60+
return nil
61+
}

internal/policy/engine_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package policy
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/sandforge/sandforge/pkg/api"
9+
)
10+
11+
func TestEvaluateMount(t *testing.T) {
12+
// Create a real temp directory for testing symlinks and path resolution
13+
tempBase := t.TempDir()
14+
15+
workspacesDir := filepath.Join(tempBase, "workspaces")
16+
err := os.MkdirAll(workspacesDir, 0755)
17+
if err != nil {
18+
t.Fatal(err)
19+
}
20+
21+
// Create a "forbidden" directory outside the allowed base
22+
forbiddenDir := filepath.Join(tempBase, "forbidden")
23+
err = os.MkdirAll(forbiddenDir, 0755)
24+
if err != nil {
25+
t.Fatal(err)
26+
}
27+
28+
// Create a symlink that tries to "escape"
29+
escapeSymlink := filepath.Join(workspacesDir, "escape-link")
30+
err = os.Symlink(forbiddenDir, escapeSymlink)
31+
if err != nil {
32+
t.Fatal(err)
33+
}
34+
35+
// Create a path that has a blocked pattern as a substring but not a segment
36+
falsePositivePath := filepath.Join(workspacesDir, "my-ssh-notes.txt")
37+
err = os.WriteFile(falsePositivePath, []byte("test"), 0644)
38+
if err != nil {
39+
t.Fatal(err)
40+
}
41+
42+
// Create a real blocked segment
43+
blockedSegmentDir := filepath.Join(workspacesDir, ".ssh")
44+
err = os.MkdirAll(blockedSegmentDir, 0755)
45+
if err != nil {
46+
t.Fatal(err)
47+
}
48+
49+
engine := &Engine{
50+
AllowedHostPrefixes: []string{
51+
workspacesDir,
52+
},
53+
BlockedHostPatterns: []string{
54+
".ssh",
55+
"forbidden",
56+
},
57+
}
58+
59+
tests := []struct {
60+
name string
61+
hostPath string
62+
wantError bool
63+
}{
64+
{
65+
name: "Valid path in workspace",
66+
hostPath: filepath.Join(workspacesDir, "task-1"),
67+
wantError: false, // We'll create it first
68+
},
69+
{
70+
name: "Exact match of allowed prefix",
71+
hostPath: workspacesDir,
72+
wantError: false,
73+
},
74+
{
75+
name: "Path outside whitelist",
76+
hostPath: tempBase, // The parent dir is not whitelisted
77+
wantError: true,
78+
},
79+
{
80+
name: "Relative path rejected",
81+
hostPath: "relative/path",
82+
wantError: true,
83+
},
84+
{
85+
name: "Symlink escape rejected",
86+
hostPath: escapeSymlink,
87+
wantError: true,
88+
},
89+
{
90+
name: "False positive (substring .ssh) now ALLOWED",
91+
hostPath: falsePositivePath,
92+
wantError: false,
93+
},
94+
{
95+
name: "Real blocked segment (.ssh) rejected",
96+
hostPath: blockedSegmentDir,
97+
wantError: true,
98+
},
99+
}
100+
101+
for _, tt := range tests {
102+
t.Run(tt.name, func(t *testing.T) {
103+
// Ensure the path exists so EvalSymlinks doesn't just fail on 'not found'
104+
if !tt.wantError || tt.name == "Real blocked segment (.ssh) rejected" || tt.name == "Symlink escape rejected" {
105+
os.MkdirAll(tt.hostPath, 0755)
106+
}
107+
108+
mount := api.WorkspaceMount{
109+
HostPath: tt.hostPath,
110+
}
111+
err := engine.EvaluateMount(mount)
112+
if (err != nil) != tt.wantError {
113+
t.Errorf("EvaluateMount() error = %v, wantError %v", err, tt.wantError)
114+
}
115+
})
116+
}
117+
}

0 commit comments

Comments
 (0)