Skip to content

Commit d75b07b

Browse files
authored
fix: v0.4.1 — security hardening, goroutine leak, fail-closed fixes (#68)
* docs: promote go install as recommended, demote homebrew tap * fix: data races, bulk-resolve action validation, hook cancel/deny paths Data races (internal/approval/store.go): - Get() and List() now return snapshots (value copies) instead of raw *Request pointers into the live map. Callers previously read fields like Status, ResolvedAt, ResolvedBy without holding the lock while watchExpiry wrote them concurrently — torn reads, potential panics. The -race detector now passes cleanly. bulk-resolve action validation (internal/proxy/server.go): - action="" or any non-deny/approve value previously defaulted to approve, silently bulk-approving entire runs on typos or empty bodies. Now returns 400 for any value outside {"approve", "deny"}. - AutoApproveRun now called BEFORE the resolve loop (fixes TOCTOU): approvals created between List() and the end of the loop are now caught by the cache rather than staying pending indefinitely. Hook cancel/deny paths (cmd/rampart/cli/hook_approval.go): - Ctrl-C (context cancellation) during the initial POST now returns hookDeny instead of hookAsk. Rampart should fail closed, not fall back to Claude Code's native permission prompt. - 200 {status: "denied"} (bulk-deny path) now returns hookDeny instead of hookAsk — the wrong security outcome previously. * fix: goroutine leak on shutdown, parse fail-closed, sessionID trim, format default Server shutdown goroutine leak (internal/proxy/server.go): - Shutdown() now calls s.approvals.Close() after HTTP server shutdown. Store.Close() signals the background cleanup goroutine to exit and unblocks watchExpiry goroutines. Previously they leaked for up to 1h (the default approval timeout) after every graceful shutdown. Parse failures fail closed in enforce mode (cmd/rampart/cli/hook.go): - Hook now returns hookDeny (not hookAllow) on stdin parse failure when mode=enforce. A Rampart bug or malformed hook payload must not silently allow a tool call in enforce mode. Monitor/audit modes remain fail-open so the agent is never blocked by a transient parsing issue. - Audit event updated: action reflects actual outcome (deny/allow). - Error message included in the hookDeny reason string. Format switch default case (cmd/rampart/cli/hook.go): - Added explicit default branch returning an error. Format is validated before the switch, so this is unreachable in practice, but it prevents a nil parsed pointer from reaching downstream code if validation and the switch ever diverge. sessionID trim in deriveRunID (cmd/rampart/cli/hook.go): - RAMPART_RUN and CLAUDE_CONVERSATION_ID were already TrimSpace'd; the sessionID parameter itself was not. A whitespace-only session_id from Claude Code would produce a garbage run_id and break grouping. Empty ID guard + poll status codes (cmd/rampart/cli/hook_approval.go): - Guard against 201 with empty ID: previously polled /v1/approvals/ (no ID) for the full timeout with no user feedback. - Poll loop now handles non-2xx: 404/410 → immediate hookDeny, 5xx → log and retry, other codes fall through. * fix: CEF log injection, service file token exposure, template injection CEF log injection (internal/audit/cef.go): - esc() and escH() now escape \n and \r in addition to \ and = (or |). An agent could previously inject newlines into command/path parameters to forge additional CEF fields in the audit log. JSONL was unaffected (json.Marshal already escapes control characters). Service file permissions (serve_install.go, setup.go): - Launchd plist and systemd unit files are now written with mode 0o600 (previously 0o644). Both files contain RAMPART_TOKEN inline. - Added os.Chmod after os.WriteFile to fix permissions on existing files (WriteFile only applies the mode bit on creation, not on overwrite). Template injection (serve_install.go): - Switched text/template -> html/template for plist generation. html/template auto-escapes XML special characters in <string> values, preventing a crafted binary path or token from closing the <string> element and injecting arbitrary plist keys. - Token is sanitized (newlines/CR/tabs stripped) before embedding in serviceConfig to prevent Environment= line injection in systemd units. XML injection (setup.go): - Added plistXMLEscape() helper: escapes &, <, >, ", ' in all string values interpolated into plist XML via fmt.Sprintf. - Added systemdEnvEscape() helper: strips newlines/CR from token before embedding in systemd Environment= directives.
1 parent ebc6736 commit d75b07b

File tree

9 files changed

+185
-48
lines changed

9 files changed

+185
-48
lines changed

cmd/rampart/cli/hook.go

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ func deriveRunID(sessionID string) string {
107107
if v := strings.TrimSpace(os.Getenv("RAMPART_RUN")); v != "" {
108108
return v
109109
}
110-
if sessionID != "" {
111-
return sessionID
110+
if v := strings.TrimSpace(sessionID); v != "" {
111+
return v
112112
}
113113
if v := strings.TrimSpace(os.Getenv("CLAUDE_CONVERSATION_ID")); v != "" {
114114
return v
@@ -297,13 +297,25 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
297297
parsed, err = parseClaudeCodeInput(os.Stdin, logger)
298298
case "cline":
299299
parsed, err = parseClineInput(os.Stdin, logger)
300+
default:
301+
// Should be unreachable — format is validated above.
302+
// Explicit default prevents a nil parsed pointer reaching
303+
// downstream code if the validation and switch ever diverge.
304+
return fmt.Errorf("hook: unhandled format %q", format)
300305
}
301306
if err != nil {
302307
logger.Warn("hook: failed to parse input", "format", format, "error", err)
303-
// Best-effort audit entry for parse failure. This is written directly
304-
// to the hook's audit file and is NOT part of the hash chain (the hook
305-
// command does not maintain chain state). The schema matches the normal
306-
// audit Event format for consistency with downstream consumers.
308+
// In enforce mode, fail closed — a parse failure must not silently
309+
// allow the tool call. In monitor/audit modes, allow through so the
310+
// agent is never blocked by a Rampart bug.
311+
outcome := "allow"
312+
hookOutcome := hookAllow
313+
if mode == "enforce" {
314+
outcome = "deny"
315+
hookOutcome = hookDeny
316+
}
317+
// Best-effort audit entry for parse failure. Written directly to
318+
// the hook's audit file (not hash-chained).
307319
parseFailureEvent := audit.Event{
308320
ID: audit.NewEventID(),
309321
Timestamp: time.Now().UTC(),
@@ -312,15 +324,15 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
312324
Tool: "unknown",
313325
Request: map[string]any{"raw_error": err.Error()},
314326
Decision: audit.EventDecision{
315-
Action: "allow",
316-
Message: fmt.Sprintf("parse failure (format=%s): %v — allowing by default", format, err),
327+
Action: outcome,
328+
Message: fmt.Sprintf("parse failure (format=%s): %v", format, err),
317329
},
318330
}
319331
if line, marshalErr := json.Marshal(parseFailureEvent); marshalErr == nil {
320332
line = append(line, '\n')
321333
_, _ = auditFile.Write(line)
322334
}
323-
return outputHookResult(cmd, format, hookAllow, false, "", "")
335+
return outputHookResult(cmd, format, hookOutcome, false, fmt.Sprintf("parse failure: %v", err), "")
324336
}
325337

326338
// Build tool call for evaluation

cmd/rampart/cli/hook_approval.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ func (c *hookApprovalClient) requestApprovalCtx(ctx context.Context, tool, comma
8686
client := &http.Client{Timeout: 5 * time.Second}
8787
resp, err := client.Do(req)
8888
if err != nil {
89+
// If the context was cancelled (e.g. user Ctrl-C), deny rather than
90+
// falling back to Claude Code's native prompt — Rampart should fail closed.
91+
if ctx.Err() != nil {
92+
c.logger.Debug("hook: context cancelled during approval create, denying", "error", err)
93+
fmt.Fprintf(os.Stderr, "⚠ Approval cancelled — denying\n")
94+
return hookDeny
95+
}
8996
if c.autoDiscovered {
9097
c.logger.Debug("hook: auto-discovered serve unreachable, falling back to hookAsk", "url", c.serveURL, "error", err)
9198
} else {
@@ -96,14 +103,22 @@ func (c *hookApprovalClient) requestApprovalCtx(ctx context.Context, tool, comma
96103
}
97104
defer resp.Body.Close()
98105

99-
// 200 means the run was already bulk-approved — no queuing needed.
106+
// 200 means the approval was already resolved (auto-approve or bulk-resolve).
107+
// Status field determines the outcome — don't assume approved.
100108
if resp.StatusCode == http.StatusOK {
101109
var autoResp struct {
102110
Status string `json:"status"`
103111
}
104-
if err := json.NewDecoder(resp.Body).Decode(&autoResp); err == nil && autoResp.Status == "approved" {
105-
c.logger.Debug("hook: run auto-approved by bulk-resolve, skipping queue")
106-
return hookAllow
112+
if err := json.NewDecoder(resp.Body).Decode(&autoResp); err == nil {
113+
switch autoResp.Status {
114+
case "approved":
115+
c.logger.Debug("hook: run auto-approved by bulk-resolve, skipping queue")
116+
return hookAllow
117+
case "denied":
118+
c.logger.Debug("hook: run auto-denied by bulk-resolve, skipping queue")
119+
fmt.Fprintf(os.Stderr, "❌ Auto-denied (run was bulk-denied)\n")
120+
return hookDeny
121+
}
107122
}
108123
c.logger.Error("hook: unexpected 200 from approval create", "url", c.serveURL)
109124
return hookAsk
@@ -122,6 +137,14 @@ func (c *hookApprovalClient) requestApprovalCtx(ctx context.Context, tool, comma
122137
return hookAsk
123138
}
124139

140+
// Guard against a serve bug returning 201 with an empty ID — polling
141+
// /v1/approvals/ (no ID) would spin silently for the full timeout.
142+
if created.ID == "" {
143+
c.logger.Error("hook: server returned 201 with empty approval ID, falling back to native prompt")
144+
fmt.Fprintf(os.Stderr, "⚠ Rampart serve returned empty approval ID, falling back to native prompt\n")
145+
return hookAsk
146+
}
147+
125148
// Print waiting message
126149
fmt.Fprintf(os.Stderr, "⏳ Approval required — approve via dashboard (%s/dashboard/) or `rampart watch`\n", c.serveURL)
127150
fmt.Fprintf(os.Stderr, " Approval ID: %s\n", created.ID)
@@ -163,6 +186,20 @@ func (c *hookApprovalClient) pollApprovalCtx(ctx context.Context, id string, tim
163186
continue
164187
}
165188

189+
// Non-2xx status codes (404, 500, etc.) mean the approval is
190+
// gone or the server is broken — don't spin silently until timeout.
191+
if resp.StatusCode == http.StatusGone || resp.StatusCode == http.StatusNotFound {
192+
resp.Body.Close()
193+
fmt.Fprintf(os.Stderr, "⚠ Approval %s no longer exists (status %d) — denying\n", id, resp.StatusCode)
194+
c.logger.Warn("hook: approval gone during poll, denying", "id", id, "status", resp.StatusCode)
195+
return hookDeny
196+
}
197+
if resp.StatusCode >= 500 {
198+
resp.Body.Close()
199+
c.logger.Warn("hook: server error during poll, retrying", "id", id, "status", resp.StatusCode)
200+
continue
201+
}
202+
166203
var status pollApprovalResponse
167204
err = json.NewDecoder(resp.Body).Decode(&status)
168205
resp.Body.Close()

cmd/rampart/cli/serve_install.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import (
2222
"path/filepath"
2323
"runtime"
2424
"strings"
25-
"text/template"
25+
"html/template"
2626

2727
"github.com/spf13/cobra"
2828
)
@@ -257,10 +257,14 @@ func newServeInstallCmd(opts *rootOptions, runner commandRunner) *cobra.Command
257257

258258
homeDir, _ := os.UserHomeDir()
259259
args := buildServiceArgs(port, opts.configPath, configDir, auditDir, mode, approvalTimeout)
260+
// Strip control characters from token before embedding in
261+
// service files. Newlines in a systemd Environment= directive
262+
// or plist <string> could inject arbitrary directives.
263+
safeToken := strings.NewReplacer("\n", "", "\r", "", "\t", "").Replace(token)
260264
cfg := serviceConfig{
261265
Binary: binary,
262266
Args: args,
263-
Token: token,
267+
Token: safeToken,
264268
LogPath: logPath(),
265269
HomeDir: homeDir,
266270
}
@@ -321,9 +325,13 @@ func installDarwin(cmd *cobra.Command, cfg serviceConfig, force, generated bool,
321325
// Ensure log directory exists.
322326
_ = os.MkdirAll(filepath.Dir(cfg.LogPath), 0o755)
323327

324-
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
328+
// 0o600: plist contains RAMPART_TOKEN — must not be world-readable.
329+
// Chmod after write because os.WriteFile only applies the mode on creation;
330+
// an existing file with wrong permissions would not be updated otherwise.
331+
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
325332
return fmt.Errorf("write plist: %w", err)
326333
}
334+
_ = os.Chmod(path, 0o600)
327335

328336
if out, err := runner("launchctl", "load", path).CombinedOutput(); err != nil {
329337
return fmt.Errorf("launchctl load: %w\n%s", err, out)
@@ -359,9 +367,12 @@ func installLinux(cmd *cobra.Command, cfg serviceConfig, force, generated bool,
359367
// Ensure log directory exists.
360368
_ = os.MkdirAll(filepath.Dir(cfg.LogPath), 0o755)
361369

362-
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
370+
// 0o600: unit file contains RAMPART_TOKEN — must not be world-readable.
371+
// Chmod after write because os.WriteFile only applies the mode on creation.
372+
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
363373
return fmt.Errorf("write unit: %w", err)
364374
}
375+
_ = os.Chmod(path, 0o600)
365376

366377
// Stop the old service before reload so the new binary/token take effect.
367378
_, _ = runner("systemctl", "--user", "stop", "rampart-serve.service").CombinedOutput()

cmd/rampart/cli/setup.go

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,26 @@ func installService(cmd *cobra.Command, home, rampartBin, policyPath, token stri
588588
return installSystemd(cmd, home, rampartBin, policyPath, token, port)
589589
}
590590

591+
// plistXMLEscape escapes a string for safe embedding inside a plist <string>
592+
// element. Unescaped '<' or '&' in a value would break the XML structure or
593+
// allow a crafted path/token to inject arbitrary plist keys.
594+
func plistXMLEscape(s string) string {
595+
s = strings.ReplaceAll(s, "&", "&amp;")
596+
s = strings.ReplaceAll(s, "<", "&lt;")
597+
s = strings.ReplaceAll(s, ">", "&gt;")
598+
s = strings.ReplaceAll(s, "\"", "&quot;")
599+
s = strings.ReplaceAll(s, "'", "&apos;")
600+
return s
601+
}
602+
603+
// systemdEnvEscape strips characters that would break a systemd
604+
// Environment= line (newlines introduce new directives).
605+
func systemdEnvEscape(s string) string {
606+
s = strings.ReplaceAll(s, "\n", "")
607+
s = strings.ReplaceAll(s, "\r", "")
608+
return s
609+
}
610+
591611
func installSystemd(cmd *cobra.Command, home, rampartBin, policyPath, token string, port int) error {
592612
serviceDir := filepath.Join(home, ".config", "systemd", "user")
593613
if err := os.MkdirAll(serviceDir, 0o700); err != nil {
@@ -607,11 +627,14 @@ Environment=RAMPART_TOKEN=%s
607627
608628
[Install]
609629
WantedBy=default.target
610-
`, rampartBin, port, policyPath, home, token)
630+
`, rampartBin, port, policyPath, home, systemdEnvEscape(token))
611631

612-
if err := os.WriteFile(servicePath, []byte(serviceContent), 0o644); err != nil {
632+
// 0o600: unit file contains RAMPART_TOKEN — must not be world-readable.
633+
// Chmod after write because os.WriteFile only applies the mode on creation.
634+
if err := os.WriteFile(servicePath, []byte(serviceContent), 0o600); err != nil {
613635
return fmt.Errorf("setup: write service file: %w", err)
614636
}
637+
_ = os.Chmod(servicePath, 0o600)
615638
fmt.Fprintf(cmd.OutOrStdout(), "✓ Systemd service written to %s\n", servicePath)
616639
return nil
617640
}
@@ -622,6 +645,7 @@ func installLaunchd(cmd *cobra.Command, home, rampartBin, policyPath, token stri
622645
return fmt.Errorf("setup: create LaunchAgents dir: %w", err)
623646
}
624647
plistPath := filepath.Join(agentDir, "com.rampart.proxy.plist")
648+
logPath := filepath.Join(home, ".rampart", "rampart-proxy.log")
625649
plistContent := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
626650
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
627651
<plist version="1.0">
@@ -654,13 +678,22 @@ func installLaunchd(cmd *cobra.Command, home, rampartBin, policyPath, token stri
654678
<string>%s</string>
655679
</dict>
656680
</plist>
657-
`, rampartBin, port, policyPath, home, token,
658-
filepath.Join(home, ".rampart", "rampart-proxy.log"),
659-
filepath.Join(home, ".rampart", "rampart-proxy.log"))
660-
661-
if err := os.WriteFile(plistPath, []byte(plistContent), 0o644); err != nil {
681+
`,
682+
plistXMLEscape(rampartBin),
683+
port,
684+
plistXMLEscape(policyPath),
685+
plistXMLEscape(home),
686+
plistXMLEscape(token),
687+
plistXMLEscape(logPath),
688+
plistXMLEscape(logPath),
689+
)
690+
691+
// 0o600: plist contains RAMPART_TOKEN — must not be world-readable.
692+
// Chmod after write because os.WriteFile only applies the mode on creation.
693+
if err := os.WriteFile(plistPath, []byte(plistContent), 0o600); err != nil {
662694
return fmt.Errorf("setup: write plist: %w", err)
663695
}
696+
_ = os.Chmod(plistPath, 0o600)
664697
fmt.Fprintf(cmd.OutOrStdout(), "✓ LaunchAgent written to %s\n", plistPath)
665698
return nil
666699
}

docs-site/getting-started/quickstart.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ Get Rampart protecting your AI agent in one command.
88
!!! tip "Zero risk to try"
99
Rampart **fails open** — if the service is unreachable or the policy engine crashes, your tools keep working. You'll never get locked out of your own machine.
1010

11+
## Install
12+
13+
=== "Go install (recommended)"
14+
15+
```bash
16+
go install github.com/peg/rampart/cmd/rampart@latest
17+
```
18+
19+
=== "Script"
20+
21+
```bash
22+
curl -fsSL https://rampart.sh/install.sh | sh
23+
```
24+
25+
=== "Homebrew"
26+
27+
```bash
28+
brew tap peg/rampart && brew install rampart
29+
```
30+
1131
## One-Command Setup
1232

1333
```bash

docs-site/getting-started/tutorial.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,29 @@ Let's set it up.
1111
## Prerequisites
1212

1313
- **macOS or Linux** (Windows WSL works too)
14-
- **Homebrew** (recommended) or **Go 1.24+** for building from source
14+
- **Go 1.24+** (recommended) or the install script for a no-Go option
1515
- **Claude Code, Codex, or Cline** — this guide uses Claude Code, but Rampart works with [many agents](../integrations/index.md)
1616

1717
---
1818

1919
## Step 1: Install
2020

21-
=== "Homebrew (recommended)"
21+
=== "Go install (recommended)"
2222

2323
```bash
24-
brew tap peg/rampart && brew install rampart
24+
go install github.com/peg/rampart/cmd/rampart@latest
2525
```
2626

27-
=== "Go install"
27+
=== "Script"
2828

2929
```bash
30-
go install github.com/peg/rampart/cmd/rampart@latest
30+
curl -fsSL https://rampart.sh/install.sh | sh
3131
```
3232

33-
=== "Script"
33+
=== "Homebrew"
3434

3535
```bash
36-
curl -fsSL https://rampart.sh/install.sh | sh
36+
brew tap peg/rampart && brew install rampart
3737
```
3838

3939
Verify:

internal/approval/store.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,18 +256,26 @@ func (s *Store) Get(id string) (*Request, bool) {
256256
defer s.mu.Unlock()
257257

258258
req, ok := s.pending[id]
259-
return req, ok
259+
if !ok {
260+
return nil, false
261+
}
262+
// Return a snapshot so callers don't race with watchExpiry writes.
263+
cp := *req
264+
return &cp, true
260265
}
261266

262-
// List returns all pending requests.
267+
// List returns snapshots of all pending requests.
268+
// Callers receive copies, not live pointers, so reads don't race with
269+
// concurrent writes from watchExpiry or Resolve.
263270
func (s *Store) List() []*Request {
264271
s.mu.Lock()
265272
defer s.mu.Unlock()
266273

267274
result := make([]*Request, 0, len(s.pending))
268275
for _, req := range s.pending {
269276
if req.Status == StatusPending {
270-
result = append(result, req)
277+
cp := *req
278+
result = append(result, &cp)
271279
}
272280
}
273281
// Sort by creation time (oldest first) for deterministic ordering.

internal/audit/cef.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,22 @@ func FormatCEF(e Event) string {
5656
}
5757
policyNames := strings.Join(e.Decision.MatchedPolicies, ",")
5858

59-
// Escape CEF extension values (\ and =).
59+
// Escape CEF extension values (\ and = and newlines).
60+
// Newlines must be escaped to prevent an agent from injecting forged
61+
// CEF fields into the audit log via tool parameters (log injection).
6062
esc := func(s string) string {
6163
s = strings.ReplaceAll(s, `\`, `\\`)
6264
s = strings.ReplaceAll(s, `=`, `\=`)
65+
s = strings.ReplaceAll(s, "\n", `\n`)
66+
s = strings.ReplaceAll(s, "\r", `\r`)
6367
return s
6468
}
65-
// Escape CEF header pipes.
69+
// Escape CEF header pipes and newlines.
6670
escH := func(s string) string {
6771
s = strings.ReplaceAll(s, `\`, `\\`)
6872
s = strings.ReplaceAll(s, `|`, `\|`)
73+
s = strings.ReplaceAll(s, "\n", `\n`)
74+
s = strings.ReplaceAll(s, "\r", `\r`)
6975
return s
7076
}
7177

0 commit comments

Comments
 (0)