Skip to content

Commit fe48ce8

Browse files
authored
Port thvignore.md (#21)
1 parent faaadad commit fe48ce8

1 file changed

Lines changed: 350 additions & 0 deletions

File tree

rfcs/THV-0021-thvignore.md

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
# **🧱 Technical Design Proposal: `.thvignore`\-Driven Bind Mount Filtering in ToolHive**
2+
3+
> [!NOTE]
4+
> This was originally [thvignore.md](https://github.com/stacklok/toolhive/blob/f8a7841687a75e08a01d3b1d807ce53b44761e31/docs/proposals/thvignore.md).
5+
6+
---
7+
8+
## **🎯 Goals**
9+
10+
| Objective | Solution |
11+
| ----- | ----- |
12+
| Exclude secrets (e.g., `.ssh`, `.env`) from containers while using bind mounts | Use `.thvignore` to drive tmpfs overlays |
13+
| Maintain real-time access to files like SQLite DBs | Bind mount full directory |
14+
| Support both global ignore patterns (e.g., user-wide) and per-project rules | Combine global and local `.thvignore` |
15+
| Provide a consistent, secure experience across all runtimes | Abstract runtime-specific mount behavior in ToolHive's execution layer |
16+
17+
---
18+
19+
## **🗂 Config File Design**
20+
21+
### **🧭 Per-folder config: `.thvignore`**
22+
23+
Lives **next to the files being mounted**.
24+
25+
```shell
26+
my-folder/
27+
├── database.db
28+
├── .ssh/
29+
└── .thvignore
30+
```
31+
32+
`.thvignore`:
33+
34+
```
35+
.ssh/
36+
*.bak
37+
.env
38+
```
39+
40+
### **🌍 Global config: `~/.config/toolhive/thvignore`**
41+
42+
Example:
43+
44+
```
45+
node_modules/
46+
.DS_Store
47+
.idea/
48+
```
49+
50+
These patterns apply to **all** mounts unless explicitly disabled.
51+
52+
---
53+
54+
## **🧠 Behavior Overview**
55+
56+
### **✅ At runtime:**
57+
58+
1. User runs:
59+
60+
```shell
61+
thv run --volume ./my-folder:/app server-name
62+
```
63+
64+
2.
65+
ToolHive does the following:
66+
67+
* Load global ignore file from `~/.config/toolhive/thvignore`
68+
69+
* Load `./my-folder/.thvignore` (if present)
70+
71+
* Combine and normalize both sets of patterns
72+
73+
* For each pattern:
74+
75+
* Determine full container path (e.g. `/app/.ssh`)
76+
77+
* Add a `tmpfs` mount over it to the runtime configuration
78+
79+
---
80+
81+
## **🧱 Component Design**
82+
83+
### **🔹 `IgnoreProcessor` (new module in `pkg/ignore/`)**
84+
85+
```go
86+
package ignore
87+
88+
type IgnoreProcessor struct {
89+
GlobalPatterns []string
90+
LocalPatterns []string
91+
}
92+
93+
func NewIgnoreProcessor() *IgnoreProcessor
94+
func (ip *IgnoreProcessor) LoadGlobal() error
95+
func (ip *IgnoreProcessor) LoadLocal(sourceDir string) error
96+
func (ip *IgnoreProcessor) GetOverlayPaths(bindMount, containerPath string) []string
97+
func (ip *IgnoreProcessor) ShouldIgnore(path string) bool
98+
```
99+
100+
*
101+
Reads `.gitignore`\-style files using existing Go libraries
102+
103+
* Integrates with ToolHive's existing mount processing pipeline
104+
105+
* Converts ignore patterns into **container absolute paths** (e.g. `/app/.ssh`)
106+
107+
---
108+
109+
### **🔹 Enhanced `runtime.Mount` (in `pkg/container/runtime/types.go`)**
110+
111+
Extend the existing Mount struct to support tmpfs:
112+
113+
```go
114+
type Mount struct {
115+
Source string
116+
Target string
117+
ReadOnly bool
118+
Type string // NEW: "bind" or "tmpfs"
119+
}
120+
```
121+
122+
Integration with existing mount processing in `pkg/runner/config_builder.go`:
123+
124+
```go
125+
func (b *RunConfigBuilder) processVolumeMounts() error {
126+
// Existing mount processing...
127+
128+
// NEW: Process ignore patterns
129+
ignoreProcessor := ignore.NewIgnoreProcessor()
130+
ignoreProcessor.LoadGlobal()
131+
ignoreProcessor.LoadLocal(sourceDir)
132+
133+
overlayPaths := ignoreProcessor.GetOverlayPaths(source, target)
134+
for _, overlayPath := range overlayPaths {
135+
b.addTmpfsOverlay(overlayPath)
136+
}
137+
}
138+
```
139+
140+
---
141+
142+
## **🧪 Runtime Support**
143+
144+
Enhanced `convertMounts` function in `pkg/container/docker/client.go`:
145+
146+
```go
147+
func convertMounts(mounts []runtime.Mount) []mount.Mount {
148+
result := make([]mount.Mount, 0, len(mounts))
149+
for _, m := range mounts {
150+
if m.Type == "tmpfs" {
151+
result = append(result, mount.Mount{
152+
Type: mount.TypeTmpfs,
153+
Target: m.Target,
154+
TmpfsOptions: &mount.TmpfsOptions{
155+
SizeBytes: 1024 * 1024, // 1MB tmpfs for security overlays
156+
},
157+
})
158+
} else {
159+
result = append(result, mount.Mount{
160+
Type: mount.TypeBind,
161+
Source: m.Source,
162+
Target: m.Target,
163+
ReadOnly: m.ReadOnly,
164+
})
165+
}
166+
}
167+
return result
168+
}
169+
```
170+
171+
| Runtime | Bind Mount | Tmpfs Overlay |
172+
| ----- | ----- | ----- |
173+
| Docker |`mount.TypeBind` |`mount.TypeTmpfs` |
174+
| Podman |`--mount type=bind` |`--mount type=tmpfs` |
175+
| Colima |`mount.TypeBind` |`mount.TypeTmpfs` |
176+
177+
---
178+
179+
## **🧰 CLI Integration**
180+
181+
Extend existing `thv run` command flags:
182+
183+
```go
184+
// In cmd/thv/app/run.go
185+
var (
186+
runIgnoreGlobally bool
187+
runPrintOverlays bool
188+
runIgnoreFile string
189+
)
190+
191+
func init() {
192+
runCmd.Flags().BoolVar(&runIgnoreGlobally, "ignore-globally", true,
193+
"Load global ignore patterns from ~/.config/toolhive/thvignore")
194+
runCmd.Flags().BoolVar(&runPrintOverlays, "print-resolved-overlays", false,
195+
"Debug: show resolved container paths for tmpfs overlays")
196+
runCmd.Flags().StringVar(&runIgnoreFile, "ignore-file", ".thvignore",
197+
"Name of the ignore file to look for in source directories")
198+
}
199+
```
200+
201+
---
202+
203+
## **🔐 Security Considerations**
204+
205+
* Warn users if sensitive-looking files (`.ssh`, `.env`) are present but not excluded
206+
207+
* Validate ignore patterns to prevent overly broad exclusions
208+
209+
* Integrate with existing permission profile system for defense-in-depth
210+
211+
* Log overlay mount creation for audit purposes
212+
213+
---
214+
215+
## **🎯 Use Cases**
216+
217+
### **🔑 Cloud Provider Credentials**
218+
219+
**Scenario**: Developer working on a project with AWS/GCP credentials that should never be accessible to MCP servers.
220+
221+
```shell
222+
my-project/
223+
├── src/
224+
├── .aws/credentials
225+
├── .gcp/service-account.json
226+
├── .env.production
227+
└── .thvignore
228+
```
229+
230+
**`.thvignore`**:
231+
```
232+
.aws/
233+
.gcp/
234+
*.pem
235+
.env.production
236+
```
237+
238+
**Result**: MCP server analyzes code in `src/` but cloud credentials are hidden via tmpfs overlays.
239+
240+
---
241+
242+
### **🏢 SSH Keys and Development Secrets**
243+
244+
**Scenario**: Developer's home directory mounted for MCP server to access project files while protecting personal credentials.
245+
246+
```shell
247+
~/dev-project/
248+
├── code/
249+
├── .ssh/id_rsa
250+
├── .gnupg/
251+
├── .docker/config.json
252+
└── .thvignore
253+
```
254+
255+
**`.thvignore`**:
256+
```
257+
.ssh/
258+
.gnupg/
259+
.docker/config.json
260+
.kube/config
261+
```
262+
263+
**Result**: MCP server can access project code but personal authentication credentials remain protected.
264+
265+
---
266+
267+
### **🤖 AI/ML Model Protection**
268+
269+
**Scenario**: Data scientist using MCP servers for code analysis while protecting sensitive datasets and production models.
270+
271+
```shell
272+
ml-project/
273+
├── notebooks/
274+
├── src/
275+
├── data/customer-data.csv
276+
├── models/production-model.pkl
277+
└── .thvignore
278+
```
279+
280+
**`.thvignore`**:
281+
```
282+
data/*.csv
283+
models/production-*
284+
*.pkl
285+
.kaggle/
286+
```
287+
288+
**Result**: MCP server can analyze notebooks and source code but cannot access sensitive data or production models.
289+
290+
---
291+
292+
## **📄 Example: Final Runtime Command**
293+
294+
If user runs:
295+
296+
```shell
297+
thv run --volume ./my-folder:/app server-name
298+
```
299+
300+
And:
301+
302+
```shell
303+
# ~/.config/toolhive/thvignore
304+
node_modules/
305+
306+
# ./my-folder/.thvignore
307+
.ssh/
308+
.env
309+
```
310+
311+
ToolHive generates runtime configuration with:
312+
313+
```go
314+
// Main bind mount
315+
runtime.Mount{
316+
Source: "/absolute/path/my-folder",
317+
Target: "/app",
318+
Type: "bind",
319+
ReadOnly: false,
320+
}
321+
322+
// Tmpfs overlays
323+
runtime.Mount{Target: "/app/.ssh", Type: "tmpfs"}
324+
runtime.Mount{Target: "/app/.env", Type: "tmpfs"}
325+
runtime.Mount{Target: "/app/node_modules", Type: "tmpfs"}
326+
```
327+
328+
Which converts to Docker commands:
329+
330+
```shell
331+
docker run \
332+
-v /absolute/path/my-folder:/app \
333+
--tmpfs /app/.ssh:rw,nosuid,nodev,noexec \
334+
--tmpfs /app/.env:rw,nosuid,nodev,noexec \
335+
--tmpfs /app/node_modules:rw,nosuid,nodev,noexec \
336+
my-image
337+
```
338+
339+
---
340+
341+
## **✅ Summary**
342+
343+
| Feature | Outcome |
344+
| ----- | ----- |
345+
| Real-time file access | ✅ via full bind mount |
346+
| Hidden files (e.g. `.ssh`, `.env`) | ✅ overlaid with tmpfs |
347+
| Config flexibility | ✅ per-folder \+ global `.thvignore` |
348+
| Runtime compatibility | ✅ Docker, Podman, Colima |
349+
| Integration | ✅ Works with existing permission profiles |
350+

0 commit comments

Comments
 (0)