Skip to content

Commit 55232bf

Browse files
authored
Fix macOS symlink path bypass, doc corrections
- P1: cleanPath -> cleanPaths returns both cleaned and resolved paths. Policies now match against both forms, fixing macOS /etc -> /private/etc bypass where existing symlinked paths could skip path_matches rules. - P2: README notify example uses correct schema (notify.url + on) - P2: Claude Desktop guide uses require_approval instead of ask - P2: ARCHITECTURE.md corrected to fail-open (matches actual behavior) - P3: PRELOAD-SPEC.md updated to reflect shipped status (v0.1.5+) All 15 test suites pass.
1 parent 9065ff0 commit 55232bf

File tree

6 files changed

+37
-30
lines changed

6 files changed

+37
-30
lines changed

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,10 +394,9 @@ version: "1"
394394
default_action: allow
395395
396396
notify:
397-
webhook:
398-
url: "https://discord.com/api/webhooks/your/webhook"
399-
# Or Slack: "https://hooks.slack.com/services/your/webhook"
400-
events: ["deny"] # Only notify on denied commands (options: deny, log, ask, allow)
397+
url: "https://discord.com/api/webhooks/your/webhook"
398+
# Or Slack: "https://hooks.slack.com/services/your/webhook"
399+
on: ["deny"] # Only notify on denied commands (options: deny, log)
401400
402401
policies:
403402
# ... your policies

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Agent → Tool Call → Rampart → Policy Engine → Allow / Deny / Log
1111

1212
## Design Decisions
1313

14-
**Fail-closed.** If Rampart crashes, tool calls fail. The agent doesn't get unrestricted access. A security layer that fails open is a logging tool.
14+
**Fail-open by default.** If Rampart crashes or is unreachable, tool calls pass through. This is deliberate — fail-closed locks you out of your own machine. See the [threat model](THREAT-MODEL.md) for trade-offs and mitigations.
1515

1616
**Custom YAML over OPA/Rego.** The domain is narrow — "should this tool call run?" — and doesn't need a general-purpose policy language. Three lines of YAML beats fifteen lines of Rego. The custom engine also evaluates in <10µs vs OPA's 0.1-1ms.
1717

docs/PRELOAD-SPEC.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
# Rampart Preload — Syscall-Level Agent Protection
22

3-
**Status:** Spec / Not yet implemented
4-
**Target:** v0.2.0
5-
**Effort:** 4-5 weeks
3+
**Status:** Shipped (v0.1.5+)
4+
**See also:** `rampart preload --help`, [README](../README.md#ld_preload)
65

76
## Overview
87

docs/guides/securing-claude-desktop.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ policies:
161161
match:
162162
tool: ["write"]
163163
rules:
164-
- action: ask
164+
- action: require_approval
165165
when:
166166
path_matches: ["**/.*"] # Hidden files
167167
message: "Writing to hidden file — approve?"

internal/engine/matcher.go

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ package engine
1515

1616
import (
1717
"fmt"
18-
"os"
1918
"path/filepath"
2019
"regexp"
2120
"strings"
@@ -28,20 +27,19 @@ import (
2827
// traversal tricks like "/etc/../etc/shadow" are normalized before
2928
// glob matching, regardless of which entry point (proxy, interceptor,
3029
// MCP, SDK) produced the path.
31-
func cleanPath(p string) string {
30+
// cleanPaths returns both the cleaned path and the symlink-resolved path.
31+
// On macOS, /etc -> /private/etc, so policies matching "/etc/**" need to
32+
// check both forms. For non-existent files, both values are the same.
33+
func cleanPaths(p string) (cleaned string, resolved string) {
3234
if p == "" {
33-
return p
35+
return p, p
3436
}
35-
cleaned := filepath.Clean(p)
36-
resolved, err := filepath.EvalSymlinks(cleaned)
37+
cleaned = filepath.Clean(p)
38+
r, err := filepath.EvalSymlinks(cleaned)
3739
if err != nil {
38-
// File may not exist yet (e.g. write to new path) — use Clean result.
39-
if os.IsNotExist(err) {
40-
return cleaned
41-
}
42-
return cleaned
40+
return cleaned, cleaned
4341
}
44-
return resolved
42+
return cleaned, r
4543
}
4644

4745
// MatchGlob reports whether name matches the glob pattern.
@@ -192,11 +190,14 @@ func ExplainCondition(cond Condition, call ToolCall) (bool, string) {
192190
}
193191

194192
if len(cond.PathMatches) > 0 {
195-
path := cleanPath(call.Path())
196-
if path == "" {
193+
cleaned, resolved := cleanPaths(call.Path())
194+
if cleaned == "" {
197195
return false, ""
198196
}
199-
matched := matchFirst(cond.PathMatches, path)
197+
matched := matchFirst(cond.PathMatches, cleaned)
198+
if matched == "" && resolved != cleaned {
199+
matched = matchFirst(cond.PathMatches, resolved)
200+
}
200201
if matched == "" {
201202
return false, ""
202203
}
@@ -259,11 +260,19 @@ func matchCondition(cond Condition, call ToolCall) bool {
259260
// Path matching (for read/write tool calls).
260261
// Canonicalize path to prevent traversal bypasses (e.g. /etc/../etc/shadow).
261262
if len(cond.PathMatches) > 0 {
262-
path := cleanPath(call.Path())
263-
if path == "" || !matchAny(cond.PathMatches, path) {
263+
cleaned, resolved := cleanPaths(call.Path())
264+
if cleaned == "" {
265+
return false
266+
}
267+
pathMatch := matchAny(cond.PathMatches, cleaned)
268+
if !pathMatch && resolved != cleaned {
269+
pathMatch = matchAny(cond.PathMatches, resolved)
270+
}
271+
if !pathMatch {
264272
return false
265273
}
266-
if matchAny(cond.PathNotMatches, path) {
274+
// Check exclusions against both forms too.
275+
if matchAny(cond.PathNotMatches, cleaned) || (resolved != cleaned && matchAny(cond.PathNotMatches, resolved)) {
267276
return false
268277
}
269278
matched = true

internal/engine/matcher_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func TestMatchGlob(t *testing.T) {
5959
}
6060
}
6161

62-
func TestCleanPath(t *testing.T) {
62+
func TestCleanPaths(t *testing.T) {
6363
tests := []struct {
6464
input string
6565
want string
@@ -72,9 +72,9 @@ func TestCleanPath(t *testing.T) {
7272
}
7373
for _, tt := range tests {
7474
t.Run(tt.input, func(t *testing.T) {
75-
got := cleanPath(tt.input)
76-
if got != tt.want {
77-
t.Errorf("cleanPath(%q) = %q, want %q", tt.input, got, tt.want)
75+
cleaned, _ := cleanPaths(tt.input)
76+
if cleaned != tt.want {
77+
t.Errorf("cleanPaths(%q) cleaned = %q, want %q", tt.input, cleaned, tt.want)
7878
}
7979
})
8080
}

0 commit comments

Comments
 (0)