Skip to content

Commit 61917e0

Browse files
authored
v0.2.35: Hook auto-reads token from ~/.rampart/token (#59)
* fix: inline RAMPART_TOKEN in Claude Code hook command Claude Code hooks don't inherit the user's shell environment, so RAMPART_TOKEN was never set at hook runtime. The hook silently fell back to local-only evaluation and events never reached the dashboard. - serve install persists token to ~/.rampart/token (0600) - setup claude-code reads token from RAMPART_TOKEN env or token file and writes e.g. 'RAMPART_TOKEN=xxx /usr/local/bin/rampart hook' - hasRampartInMatcher recognises RAMPART_TOKEN= prefixed commands - 2 new tests: InlinesToken (env) + InlinesTokenFromFile * fix: hook auto-reads token from ~/.rampart/token Rather than inlining RAMPART_TOKEN in settings.json (security risk), make the hook itself read ~/.rampart/token as a fallback when the env var is not set. This mirrors the existing URL auto-discovery pattern. - serve install persists token to ~/.rampart/token (0600) - hook reads ~/.rampart/token when RAMPART_TOKEN env is unset - setup claude-code writes bare '/path/to/rampart hook' — no creds settings.json now contains no credentials whatsoever. * ux: setup claude-code shows dashboard auth status * fix: dashboard reads hook audit files (audit-hook-YYYY-MM-DD.jsonl) The hook writes audit events to audit-hook-YYYY-MM-DD.jsonl while serve writes to YYYY-MM-DD.jsonl. auditFilesForDate only matched the serve prefix, so the dashboard showed 0 events even though the hook was correctly recording them. Now matches both prefixes. handleAuditDates similarly updated to surface dates that only have hook files. * fix: serve install --force unloads old service before reload Two bugs: 1. --force wrote new plist + launchctl load but never unloaded the old service, so the old binary/token kept running. launchctl unload is now called first (best-effort) on both macOS and Linux. 2. persistToken failure was silently ignored, leaving plist token and ~/.rampart/token out of sync. Now warns with recovery command. * ux: always show full token in serve install output Generated and reused tokens both show the full value now. User needs the full token to auth to the dashboard — truncating it was just friction. Simplified output: dashboard URL, full token, token file pointer. * fix: TestResolveToken_Generated isolates from real ~/.rampart/token The test was leaking into the real home dir, so machines that had run serve install would have a token file and the test would see generated=false. * fix+test: token file permissions, hook guard, audit hook file coverage - persistToken: add os.Chmod after WriteFile so pre-existing files with wrong permissions (e.g. 0644) are always corrected to 0600 - hook.go: add tok != "" guard after readPersistedToken for consistency with resolveServiceToken - New tests: - TestPersistAndReadToken: roundtrip + 0600 permission check - TestPersistToken_FixesPermissions: chmod on pre-existing 0644 file - TestResolveToken_FromFile: resolveServiceToken reads from ~/.rampart/token - TestAuditDates_WithHookFiles: handleAuditDates returns hook file dates - TestAuditEvents_HookFilesIncluded: auditFilesForDate reads hook files
1 parent 9c0e2d5 commit 61917e0

File tree

7 files changed

+237
-17
lines changed

7 files changed

+237
-17
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.35] — 2026-02-18
11+
12+
### Fixed
13+
- `rampart hook` now auto-reads the token from `~/.rampart/token` when `RAMPART_TOKEN` is not set in the environment. Claude Code hooks don't inherit the user's shell environment, so the token was never available at hook runtime — events silently fell back to local-only evaluation and never reached the dashboard. The hook now discovers both the serve URL (`localhost:18275`) and the token from standard locations, with no credentials needed in `settings.json`.
14+
- `rampart serve install` now persists the generated token to `~/.rampart/token` (mode 0600). This is the canonical token location the hook reads from automatically.
15+
- Dashboard now shows hook events. The hook and serve both write to `~/.rampart/audit/` but used different filename prefixes (`audit-hook-YYYY-MM-DD.jsonl` vs `YYYY-MM-DD.jsonl`). The audit API now reads both, so all events appear in the History tab regardless of which component wrote them.
16+
1017
## [0.2.34] — 2026-02-18
1118

1219
### Fixed

cmd/rampart/cli/hook.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
124124
if serveToken == "" {
125125
serveToken = os.Getenv("RAMPART_TOKEN")
126126
}
127+
// Auto-read token from ~/.rampart/token when not set via env/flag.
128+
// This means settings.json never needs to contain credentials —
129+
// the hook discovers both the URL and the token from standard locations.
130+
if serveToken == "" {
131+
if tok, err := readPersistedToken(); err == nil && tok != "" {
132+
serveToken = tok
133+
}
134+
}
127135

128136
if mode != "enforce" && mode != "monitor" && mode != "audit" {
129137
return fmt.Errorf("hook: invalid mode %q (must be enforce, monitor, or audit)", mode)

cmd/rampart/cli/serve_install.go

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,56 @@ func resolveServiceToken(tokenFlag string) (string, bool, error) {
119119
if env := os.Getenv("RAMPART_TOKEN"); env != "" {
120120
return env, false, nil
121121
}
122+
// Check persisted token file written by a previous serve install.
123+
if tok, err := readPersistedToken(); err == nil && tok != "" {
124+
return tok, false, nil
125+
}
122126
b := make([]byte, 16)
123127
if _, err := rand.Read(b); err != nil {
124128
return "", false, fmt.Errorf("generate token: %w", err)
125129
}
126130
return hex.EncodeToString(b), true, nil
127131
}
128132

133+
// tokenFilePath returns the path to the persisted token file.
134+
func tokenFilePath() (string, error) {
135+
home, err := os.UserHomeDir()
136+
if err != nil {
137+
return "", err
138+
}
139+
return filepath.Join(home, ".rampart", "token"), nil
140+
}
141+
142+
// persistToken writes the token to ~/.rampart/token (0600).
143+
// If the file already exists, permissions are explicitly set to 0o600 regardless
144+
// of what they were before — os.WriteFile only applies the mode on creation.
145+
func persistToken(token string) error {
146+
p, err := tokenFilePath()
147+
if err != nil {
148+
return err
149+
}
150+
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
151+
return err
152+
}
153+
if err := os.WriteFile(p, []byte(token), 0o600); err != nil {
154+
return err
155+
}
156+
return os.Chmod(p, 0o600)
157+
}
158+
159+
// readPersistedToken reads the token from ~/.rampart/token if it exists.
160+
func readPersistedToken() (string, error) {
161+
p, err := tokenFilePath()
162+
if err != nil {
163+
return "", err
164+
}
165+
b, err := os.ReadFile(p)
166+
if err != nil {
167+
return "", err
168+
}
169+
return strings.TrimSpace(string(b)), nil
170+
}
171+
129172
func plistPath() (string, error) {
130173
home, err := os.UserHomeDir()
131174
if err != nil {
@@ -261,6 +304,12 @@ func installDarwin(cmd *cobra.Command, cfg serviceConfig, force, generated bool,
261304
return nil
262305
}
263306

307+
// Unload any existing service before writing new plist.
308+
// Best-effort: ignore errors if the service wasn't loaded.
309+
if _, err := os.Stat(path); err == nil {
310+
_, _ = runner("launchctl", "unload", path).CombinedOutput()
311+
}
312+
264313
content, err := generatePlist(cfg)
265314
if err != nil {
266315
return fmt.Errorf("generate plist: %w", err)
@@ -280,6 +329,10 @@ func installDarwin(cmd *cobra.Command, cfg serviceConfig, force, generated bool,
280329
return fmt.Errorf("launchctl load: %w\n%s", err, out)
281330
}
282331

332+
if err := persistToken(cfg.Token); err != nil {
333+
fmt.Fprintf(cmd.ErrOrStderr(), "⚠ Warning: could not save token to ~/.rampart/token: %v\n", err)
334+
fmt.Fprintf(cmd.ErrOrStderr(), " Run: echo '%s' > ~/.rampart/token\n", cfg.Token)
335+
}
283336
printSuccess(cmd, cfg.Token, generated, port, path)
284337
return nil
285338
}
@@ -310,13 +363,20 @@ func installLinux(cmd *cobra.Command, cfg serviceConfig, force, generated bool,
310363
return fmt.Errorf("write unit: %w", err)
311364
}
312365

366+
// Stop the old service before reload so the new binary/token take effect.
367+
_, _ = runner("systemctl", "--user", "stop", "rampart-serve.service").CombinedOutput()
368+
313369
if out, err := runner("systemctl", "--user", "daemon-reload").CombinedOutput(); err != nil {
314370
return fmt.Errorf("systemctl daemon-reload: %w\n%s", err, out)
315371
}
316372
if out, err := runner("systemctl", "--user", "enable", "--now", "rampart-serve.service").CombinedOutput(); err != nil {
317373
return fmt.Errorf("systemctl enable: %w\n%s", err, out)
318374
}
319375

376+
if err := persistToken(cfg.Token); err != nil {
377+
fmt.Fprintf(cmd.ErrOrStderr(), "⚠ Warning: could not save token to ~/.rampart/token: %v\n", err)
378+
fmt.Fprintf(cmd.ErrOrStderr(), " Run: echo '%s' > ~/.rampart/token\n", cfg.Token)
379+
}
320380
printSuccess(cmd, cfg.Token, generated, port, path)
321381
return nil
322382
}
@@ -325,17 +385,10 @@ func printSuccess(cmd *cobra.Command, token string, generated bool, port int, pa
325385
w := cmd.ErrOrStderr()
326386
fmt.Fprintf(w, "\n✅ Rampart service installed: %s\n", path)
327387
fmt.Fprintf(w, " Dashboard: http://localhost:%d/dashboard/\n", port)
388+
fmt.Fprintf(w, " Token: %s\n", token)
389+
fmt.Fprintf(w, " (token also saved to ~/.rampart/token — hooks read it automatically)\n")
328390
if generated {
329-
fmt.Fprintf(w, "\n🔑 Generated token (save this — you'll need it for hooks):\n")
330-
fmt.Fprintf(w, " export RAMPART_TOKEN=%s\n\n", token)
331-
fmt.Fprintf(w, " Add to your shell profile so it persists across sessions:\n")
332-
fmt.Fprintf(w, " echo 'export RAMPART_TOKEN=%s' >> ~/.zshrc # zsh (macOS default)\n", token)
333-
fmt.Fprintf(w, " echo 'export RAMPART_TOKEN=%s' >> ~/.bashrc # bash\n\n", token)
334-
} else {
335-
display := token
336-
if len(token) > 8 {
337-
display = token[:8] + "..."
338-
}
339-
fmt.Fprintf(w, " Token: %s\n", display)
391+
fmt.Fprintf(w, "\n To persist across shell sessions:\n")
392+
fmt.Fprintf(w, " echo 'export RAMPART_TOKEN=%s' >> ~/.zshrc\n", token)
340393
}
341394
}

cmd/rampart/cli/serve_install_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
package cli
1515

1616
import (
17+
"os"
1718
"os/exec"
19+
"path/filepath"
1820
"strings"
1921
"testing"
2022
)
@@ -93,6 +95,7 @@ func TestResolveToken_FromEnv(t *testing.T) {
9395

9496
func TestResolveToken_Generated(t *testing.T) {
9597
t.Setenv("RAMPART_TOKEN", "")
98+
t.Setenv("HOME", t.TempDir()) // prevent reading ~/.rampart/token from real home
9699
tok, gen, err := resolveServiceToken("")
97100
if err != nil {
98101
t.Fatal(err)
@@ -105,6 +108,86 @@ func TestResolveToken_Generated(t *testing.T) {
105108
}
106109
}
107110

111+
func TestPersistAndReadToken(t *testing.T) {
112+
home := t.TempDir()
113+
t.Setenv("HOME", home)
114+
115+
// File doesn't exist yet — readPersistedToken should return an error.
116+
if _, err := readPersistedToken(); err == nil {
117+
t.Fatal("expected error reading token from empty home, got nil")
118+
}
119+
120+
// Persist a token.
121+
const want = "abc123deadbeef"
122+
if err := persistToken(want); err != nil {
123+
t.Fatalf("persistToken: %v", err)
124+
}
125+
126+
// File must be 0o600.
127+
p, _ := tokenFilePath()
128+
info, err := os.Stat(p)
129+
if err != nil {
130+
t.Fatalf("stat token file: %v", err)
131+
}
132+
if info.Mode().Perm() != 0o600 {
133+
t.Errorf("token file permissions: got %04o, want 0600", info.Mode().Perm())
134+
}
135+
136+
// Round-trip: read back the token.
137+
got, err := readPersistedToken()
138+
if err != nil {
139+
t.Fatalf("readPersistedToken: %v", err)
140+
}
141+
if got != want {
142+
t.Errorf("got %q, want %q", got, want)
143+
}
144+
}
145+
146+
func TestPersistToken_FixesPermissions(t *testing.T) {
147+
home := t.TempDir()
148+
t.Setenv("HOME", home)
149+
150+
// Create the token file manually with wrong permissions.
151+
p, _ := tokenFilePath()
152+
_ = os.MkdirAll(filepath.Dir(p), 0o700)
153+
_ = os.WriteFile(p, []byte("oldtoken"), 0o644)
154+
155+
// persistToken must fix permissions, not just overwrite content.
156+
if err := persistToken("newtoken"); err != nil {
157+
t.Fatalf("persistToken: %v", err)
158+
}
159+
info, err := os.Stat(p)
160+
if err != nil {
161+
t.Fatalf("stat: %v", err)
162+
}
163+
if info.Mode().Perm() != 0o600 {
164+
t.Errorf("permissions not fixed: got %04o, want 0600", info.Mode().Perm())
165+
}
166+
}
167+
168+
func TestResolveToken_FromFile(t *testing.T) {
169+
home := t.TempDir()
170+
t.Setenv("HOME", home)
171+
t.Setenv("RAMPART_TOKEN", "")
172+
173+
// Write a token to the file.
174+
const want = "filetokendeadbeef"
175+
if err := persistToken(want); err != nil {
176+
t.Fatalf("persistToken: %v", err)
177+
}
178+
179+
tok, gen, err := resolveServiceToken("")
180+
if err != nil {
181+
t.Fatal(err)
182+
}
183+
if gen {
184+
t.Error("expected generated=false when token comes from file")
185+
}
186+
if tok != want {
187+
t.Errorf("got %q, want %q", tok, want)
188+
}
189+
}
190+
108191
func TestBuildServiceArgs(t *testing.T) {
109192
args := buildServiceArgs(8080, "/etc/rampart.yaml", "/etc/policies", "/var/audit", "monitor", "10m")
110193
want := []string{"--port", "8080", "--config", "/etc/rampart.yaml", "--config-dir", "/etc/policies", "--audit-dir", "/var/audit", "--mode", "monitor", "--approval-timeout", "10m"}

cmd/rampart/cli/setup.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ Use --remove to uninstall the Rampart hooks from Claude Code settings.`,
9797

9898
// Build the hook config — no --serve-url needed, hook auto-discovers on localhost:18275.
9999
// Use absolute path so the hook works regardless of Claude Code's PATH.
100+
// The hook reads RAMPART_TOKEN from ~/.rampart/token automatically, so
101+
// settings.json never needs to contain credentials.
100102
hookBin := "rampart"
101103
if exe, err := os.Executable(); err == nil {
102104
hookBin = exe
@@ -168,10 +170,18 @@ Use --remove to uninstall the Rampart hooks from Claude Code settings.`,
168170
fmt.Fprintln(cmd.OutOrStdout(), " Claude Code will now route Bash commands through Rampart.")
169171
fmt.Fprintln(cmd.OutOrStdout(), " Run 'claude' normally — no wrapper needed.")
170172
fmt.Fprintln(cmd.OutOrStdout(), "")
171-
fmt.Fprintln(cmd.OutOrStdout(), " Dashboard approvals:")
172-
fmt.Fprintln(cmd.OutOrStdout(), " Run 'rampart serve' to enable dashboard-based approvals.")
173-
fmt.Fprintln(cmd.OutOrStdout(), " The hook auto-discovers serve on localhost:18275.")
174-
fmt.Fprintln(cmd.OutOrStdout(), " Set RAMPART_TOKEN env var to authenticate with serve.")
173+
174+
// Tell the user whether dashboard auth is wired up.
175+
if tok, _ := readPersistedToken(); tok != "" {
176+
fmt.Fprintln(cmd.OutOrStdout(), " Dashboard: token auto-detected from ~/.rampart/token ✓")
177+
fmt.Fprintln(cmd.OutOrStdout(), " Events will appear in the dashboard automatically.")
178+
} else if os.Getenv("RAMPART_TOKEN") != "" {
179+
fmt.Fprintln(cmd.OutOrStdout(), " Dashboard: token detected from RAMPART_TOKEN env ✓")
180+
fmt.Fprintln(cmd.OutOrStdout(), " Events will appear in the dashboard automatically.")
181+
} else {
182+
fmt.Fprintln(cmd.OutOrStdout(), " Dashboard: no token found — running in local-only mode.")
183+
fmt.Fprintln(cmd.OutOrStdout(), " Run 'rampart serve install' first to enable the dashboard.")
184+
}
175185

176186
// Check if rampart is in system PATH.
177187
if _, err := execLookPath("rampart"); err != nil {

internal/proxy/audit_handlers.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,12 @@ func (s *Server) handleAuditDates(w http.ResponseWriter, r *http.Request) {
170170
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
171171
continue
172172
}
173-
// Extract date from filename: "2026-02-18.jsonl" or "2026-02-18.p1.jsonl"
173+
// Extract date from filename: "2026-02-18.jsonl", "2026-02-18.p1.jsonl",
174+
// or hook's "audit-hook-2026-02-18.jsonl".
174175
name := e.Name()
176+
if strings.HasPrefix(name, "audit-hook-") {
177+
name = strings.TrimPrefix(name, "audit-hook-")
178+
}
175179
if len(name) >= 10 {
176180
d := name[:10]
177181
if _, err := time.Parse("2006-01-02", d); err == nil {
@@ -313,7 +317,9 @@ func (s *Server) auditFilesForDate(date string) []string {
313317
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
314318
continue
315319
}
316-
if strings.HasPrefix(e.Name(), date) {
320+
// Match serve's own files ("YYYY-MM-DD.jsonl", "YYYY-MM-DD.pN.jsonl")
321+
// and hook's files ("audit-hook-YYYY-MM-DD.jsonl") — both land in the same dir.
322+
if strings.HasPrefix(e.Name(), date) || strings.HasPrefix(e.Name(), "audit-hook-"+date) {
317323
files = append(files, filepath.Join(s.auditDir, e.Name()))
318324
}
319325
}

internal/proxy/audit_handlers_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,56 @@ func TestAuditNoDir(t *testing.T) {
229229
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode, "path: %s", path)
230230
}
231231
}
232+
233+
func writeHookAuditFile(t *testing.T, dir, date string, events []map[string]any) {
234+
t.Helper()
235+
path := filepath.Join(dir, "audit-hook-"+date+".jsonl")
236+
var lines []string
237+
for _, evt := range events {
238+
b, err := json.Marshal(evt)
239+
require.NoError(t, err)
240+
lines = append(lines, string(b))
241+
}
242+
require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644))
243+
}
244+
245+
func TestAuditDates_WithHookFiles(t *testing.T) {
246+
dir := t.TempDir()
247+
// Serve writes one date, hook writes another — both should appear.
248+
writeAuditFile(t, dir, "2026-02-17", []map[string]any{{"id": "serve-01"}})
249+
writeHookAuditFile(t, dir, "2026-02-18", []map[string]any{{"id": "hook-01"}})
250+
251+
ts, token := setupAuditTestServer(t, dir)
252+
253+
resp := doGet(t, ts, token, "/v1/audit/dates")
254+
defer resp.Body.Close()
255+
require.Equal(t, http.StatusOK, resp.StatusCode)
256+
257+
var body map[string]any
258+
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
259+
dates := body["dates"].([]any)
260+
assert.Equal(t, 2, len(dates), "expected both serve and hook dates")
261+
assert.Equal(t, "2026-02-18", dates[0])
262+
assert.Equal(t, "2026-02-17", dates[1])
263+
}
264+
265+
func TestAuditEvents_HookFilesIncluded(t *testing.T) {
266+
dir := t.TempDir()
267+
// Serve file is empty; hook file has events — dashboard should see them.
268+
writeAuditFile(t, dir, "2026-02-18", []map[string]any{})
269+
writeHookAuditFile(t, dir, "2026-02-18", []map[string]any{
270+
{"id": "hook-01", "decision": map[string]any{"action": "allow"}},
271+
{"id": "hook-02", "decision": map[string]any{"action": "deny"}},
272+
})
273+
274+
ts, token := setupAuditTestServer(t, dir)
275+
276+
resp := doGet(t, ts, token, "/v1/audit/events?date=2026-02-18")
277+
defer resp.Body.Close()
278+
require.Equal(t, http.StatusOK, resp.StatusCode)
279+
280+
var body map[string]any
281+
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
282+
total := int(body["total_in_file"].(float64))
283+
assert.Equal(t, 2, total, "expected hook file events to be counted")
284+
}

0 commit comments

Comments
 (0)