Skip to content

Commit b6ce72b

Browse files
authored
Merge pull request #83 from peg/staging
release: v0.4.6 — port fix, env var injection blocking, hook feedback on deny
2 parents def31e6 + e8ab93f commit b6ce72b

File tree

10 files changed

+286
-39
lines changed

10 files changed

+286
-39
lines changed

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.4.6] - 2026-02-21
11+
12+
### Fixed
13+
- Stale port 18275 in `rampart hook --help` and `rampart setup` comment — both now correctly show port 9090 (matching `defaultServePort`)
14+
- `rampart preload` defaulted to port 19090 while `rampart serve` defaults to 9090 — they couldn't talk to each other at their defaults. Port now defaults to `defaultServePort` (9090). `RAMPART_URL` env var is also now respected and takes precedence over `--port`.
15+
16+
### Added
17+
- **`block-env-var-injection` policy** in `standard.yaml` — hard-denies env var injection with no legitimate agent use: `LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, `LD_AUDIT`, `PYTHONSTARTUP`, `PYTHONHOME`, `DOTNET_STARTUP_HOOKS`, `BASH_ENV`, `_JAVA_OPTIONS`, `PERL5OPT`, `GIT_EXEC_PATH`. These were previously bypassing glob rules because env-var prefixes are stripped before command matching.
18+
- **`watch-env-var-override` policy** in `standard.yaml` — audits (but does not block) env var overrides that are common in legitimate dev workflows but are also injection vectors: `LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`, `NODE_OPTIONS`, `NODE_PATH`, `PYTHONPATH`, `JAVA_OPTS`, `JVM_OPTS`, `JAVA_TOOL_OPTIONS`, `GIT_SSH_COMMAND`, `GIT_SSH`, `RUBYOPT`. Logged for audit trail; upgrade to `require_approval` in your `custom.yaml` if you want gating.
19+
- **PostToolUseFailure hook feedback** — when Rampart denies a `PreToolUse` event, the `PostToolUseFailure` handler now injects `additionalContext` telling Claude Code not to retry the blocked action. Prevents Claude from burning 3–5 turns on workarounds after a deny.
20+
1021
## [0.4.5] — 2026-02-21
1122

1223
### Added

cmd/rampart/cli/hook.go

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type hookDecision struct {
5353
HookEventName string `json:"hookEventName"`
5454
PermissionDecision string `json:"permissionDecision,omitempty"`
5555
PermissionDecisionReason string `json:"permissionDecisionReason,omitempty"`
56+
AdditionalContext string `json:"additionalContext,omitempty"`
5657
}
5758

5859
// clineHookInput is the JSON sent by Cline on stdin for PreToolUse hooks.
@@ -81,11 +82,12 @@ type clineHookOutput struct {
8182

8283
// hookParseResult holds the parsed hook input including optional response data.
8384
type hookParseResult struct {
84-
Tool string
85-
Params map[string]any
86-
Agent string
87-
Response string // non-empty for PostToolUse events
88-
RunID string // run ID derived from session_id (or env overrides)
85+
Tool string
86+
Params map[string]any
87+
Agent string
88+
Response string // non-empty for PostToolUse events
89+
RunID string // run ID derived from session_id (or env overrides)
90+
HookEventName string // e.g. "PreToolUse", "PostToolUse", "PostToolUseFailure"
8991
}
9092

9193
// gitContext holds the git repository context for the current working directory.
@@ -335,6 +337,22 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
335337
return outputHookResult(cmd, format, hookOutcome, false, fmt.Sprintf("parse failure: %v", err), "")
336338
}
337339

340+
// Short-circuit for PostToolUseFailure: the previous PreToolUse was denied by
341+
// Rampart. Inject additionalContext telling Claude to stop retrying rather than
342+
// burning 3-5 turns on workarounds.
343+
if parsed.HookEventName == "PostToolUseFailure" {
344+
msg := "This tool call failed or was blocked by a security policy. " +
345+
"Do not attempt alternative approaches or workarounds — " +
346+
"if an operation is restricted, report it to the user and stop."
347+
out := hookOutput{
348+
HookSpecificOutput: &hookDecision{
349+
HookEventName: "PostToolUseFailure",
350+
AdditionalContext: msg,
351+
},
352+
}
353+
return json.NewEncoder(cmd.OutOrStdout()).Encode(out)
354+
}
355+
338356
// Build tool call for evaluation
339357
call := engine.ToolCall{
340358
ID: audit.NewEventID(),
@@ -427,7 +445,7 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
427445
cmd.Flags().StringVar(&mode, "mode", "enforce", "Mode: enforce | monitor | audit")
428446
cmd.Flags().StringVar(&format, "format", "claude-code", "Input format: claude-code | cline")
429447
cmd.Flags().StringVar(&auditDir, "audit-dir", "", "Directory for audit logs (default: ~/.rampart/audit)")
430-
cmd.Flags().StringVar(&serveURL, "serve-url", "", "URL of rampart serve instance (default: auto-discover on localhost:18275, env: RAMPART_SERVE_URL)")
448+
cmd.Flags().StringVar(&serveURL, "serve-url", "", "URL of rampart serve instance (default: auto-discover on localhost:9090, env: RAMPART_SERVE_URL)")
431449
cmd.Flags().StringVar(&serveToken, "serve-token", "", "Auth token for rampart serve (env: RAMPART_TOKEN)")
432450
cmd.Flags().MarkDeprecated("serve-token", "use RAMPART_TOKEN env var instead (--serve-token is visible in process list)")
433451
cmd.Flags().StringVar(&configDir, "config-dir", "", "Directory of additional policy YAML files (default: ~/.rampart/policies/ if it exists)")
@@ -450,10 +468,11 @@ func parseClaudeCodeInput(reader interface{ Read([]byte) (int, error) }, logger
450468
}
451469

452470
result := &hookParseResult{
453-
Tool: toolType,
454-
Params: params,
455-
Agent: "claude-code",
456-
RunID: deriveRunID(input.SessionID),
471+
Tool: toolType,
472+
Params: params,
473+
Agent: "claude-code",
474+
RunID: deriveRunID(input.SessionID),
475+
HookEventName: input.HookEventName,
457476
}
458477

459478
// Extract response text from PostToolUse tool_response.

cmd/rampart/cli/hook_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,79 @@ func TestOutputHookResult_ClaudeCode_Ask(t *testing.T) {
359359
}
360360
}
361361

362+
// TestPostToolUseFailure_ShortCircuit verifies that a PostToolUseFailure hook event
363+
// produces an additionalContext response without policy evaluation.
364+
func TestPostToolUseFailure_ShortCircuit(t *testing.T) {
365+
t.Run("parseClaudeCodeInput extracts HookEventName", func(t *testing.T) {
366+
input := map[string]any{
367+
"hook_event_name": "PostToolUseFailure",
368+
"tool_name": "Bash",
369+
"tool_input": map[string]any{"command": "rm -rf /"},
370+
"session_id": "sess-abc",
371+
}
372+
data, _ := json.Marshal(input)
373+
result, err := parseClaudeCodeInput(strings.NewReader(string(data)), testLogger())
374+
if err != nil {
375+
t.Fatalf("parseClaudeCodeInput error: %v", err)
376+
}
377+
if result.HookEventName != "PostToolUseFailure" {
378+
t.Fatalf("HookEventName = %q, want PostToolUseFailure", result.HookEventName)
379+
}
380+
if result.Tool != "exec" {
381+
t.Fatalf("Tool = %q, want exec", result.Tool)
382+
}
383+
})
384+
385+
t.Run("PostToolUseFailure output contains additionalContext", func(t *testing.T) {
386+
// Simulate what the hook RunE short-circuit produces.
387+
cmd := &cobra.Command{}
388+
out := &bytes.Buffer{}
389+
cmd.SetOut(out)
390+
391+
msg := "This tool call was blocked by a Rampart policy rule. " +
392+
"This is a deliberate security constraint — do not attempt " +
393+
"alternative approaches or workarounds. " +
394+
"Tell the user the operation was blocked by policy and stop."
395+
hookOut := hookOutput{
396+
HookSpecificOutput: &hookDecision{
397+
HookEventName: "PostToolUseFailure",
398+
AdditionalContext: msg,
399+
},
400+
}
401+
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(hookOut); err != nil {
402+
t.Fatalf("encode hookOutput: %v", err)
403+
}
404+
405+
var got hookOutput
406+
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
407+
t.Fatalf("unmarshal output: %v", err)
408+
}
409+
if got.HookSpecificOutput == nil {
410+
t.Fatal("expected non-nil hookSpecificOutput")
411+
}
412+
if got.HookSpecificOutput.HookEventName != "PostToolUseFailure" {
413+
t.Fatalf("hookEventName = %q, want PostToolUseFailure", got.HookSpecificOutput.HookEventName)
414+
}
415+
if got.HookSpecificOutput.AdditionalContext == "" {
416+
t.Fatal("additionalContext must not be empty")
417+
}
418+
if !strings.Contains(got.HookSpecificOutput.AdditionalContext, "blocked by a Rampart policy rule") {
419+
t.Fatalf("additionalContext = %q, want to contain 'blocked by a Rampart policy rule'", got.HookSpecificOutput.AdditionalContext)
420+
}
421+
if !strings.Contains(got.HookSpecificOutput.AdditionalContext, "do not attempt") {
422+
t.Fatalf("additionalContext = %q, should contain 'do not attempt'", got.HookSpecificOutput.AdditionalContext)
423+
}
424+
// Ensure no PermissionDecision field — this is not a PreToolUse response
425+
if got.HookSpecificOutput.PermissionDecision != "" {
426+
t.Fatalf("PermissionDecision should be empty for PostToolUseFailure, got %q", got.HookSpecificOutput.PermissionDecision)
427+
}
428+
// Ensure top-level decision is empty (PostToolUseFailure uses hookSpecificOutput)
429+
if got.Decision != "" {
430+
t.Fatalf("top-level Decision should be empty, got %q", got.Decision)
431+
}
432+
})
433+
}
434+
362435
func TestMapClaudeCodeTool(t *testing.T) {
363436
tests := []struct {
364437
input string

cmd/rampart/cli/preload.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ func newPreloadCmd(_ *rootOptions) *cobra.Command {
5757
}
5858

5959
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
60+
if envURL := strings.TrimSpace(os.Getenv("RAMPART_URL")); envURL != "" {
61+
baseURL = strings.TrimRight(envURL, "/")
62+
}
6063
if !isPreloadRuntimeReady(cmd.Context(), baseURL) {
6164
fmt.Fprintf(cmd.ErrOrStderr(), "preload: warning: rampart serve is not reachable at %s/healthz; continuing\n", baseURL)
6265
}
@@ -107,7 +110,7 @@ func newPreloadCmd(_ *rootOptions) *cobra.Command {
107110
},
108111
}
109112

110-
cmd.Flags().IntVar(&port, "port", 19090, "Port for rampart serve")
113+
cmd.Flags().IntVar(&port, "port", defaultServePort, "Port for rampart serve (default matches 'rampart serve' default)")
111114
cmd.Flags().StringVar(&token, "token", "", "Auth token (or set RAMPART_TOKEN)")
112115
cmd.Flags().StringVar(&mode, "mode", "enforce", "Mode: enforce | monitor | disabled")
113116
cmd.Flags().BoolVar(&failOpen, "fail-open", true, "Whether to fail open")

cmd/rampart/cli/setup.go

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ Use --remove to uninstall the Rampart hooks from Claude Code settings.`,
9797
return nil
9898
}
9999

100-
// Build the hook config — no --serve-url needed, hook auto-discovers on localhost:18275.
100+
// Build the hook config — no --serve-url needed, hook auto-discovers on localhost:9090.
101101
// Use absolute path so the hook works regardless of Claude Code's PATH.
102102
// The hook reads RAMPART_TOKEN from ~/.rampart/token automatically, so
103103
// settings.json never needs to contain credentials.
@@ -126,14 +126,21 @@ Use --remove to uninstall the Rampart hooks from Claude Code settings.`,
126126
"matcher": "Write|Edit",
127127
"hooks": []any{rampartHook},
128128
}
129+
// PostToolUseFailure: fires when Claude Code denies a tool after PreToolUse.
130+
// Matcher ".*" catches all tools so Rampart can inject additionalContext
131+
// telling Claude to stop retrying instead of burning turns on workarounds.
132+
postToolUseFailureMatcher := map[string]any{
133+
"matcher": ".*",
134+
"hooks": []any{rampartHook},
135+
}
129136

130137
// Get or create hooks section
131138
hooks, ok := settings["hooks"].(map[string]any)
132139
if !ok {
133140
hooks = make(map[string]any)
134141
}
135142

136-
// Get or create PreToolUse array
143+
// Get or create PreToolUse array (dedup existing rampart entries)
137144
var preToolUse []any
138145
if existing, ok := hooks["PreToolUse"].([]any); ok {
139146
// Filter out any existing rampart hooks
@@ -148,7 +155,22 @@ Use --remove to uninstall the Rampart hooks from Claude Code settings.`,
148155
}
149156
preToolUse = append(preToolUse, bashMatcher, readMatcher, writeMatcher)
150157

158+
// Get or create PostToolUseFailure array (dedup existing rampart entries)
159+
var postToolUseFailure []any
160+
if existing, ok := hooks["PostToolUseFailure"].([]any); ok {
161+
for _, h := range existing {
162+
if m, ok := h.(map[string]any); ok {
163+
if hasRampartInMatcher(m) {
164+
continue
165+
}
166+
}
167+
postToolUseFailure = append(postToolUseFailure, h)
168+
}
169+
}
170+
postToolUseFailure = append(postToolUseFailure, postToolUseFailureMatcher)
171+
151172
hooks["PreToolUse"] = preToolUse
173+
hooks["PostToolUseFailure"] = postToolUseFailure
152174
settings["hooks"] = hooks
153175

154176
// Ensure directory exists
@@ -231,35 +253,51 @@ func removeClaudeCodeHooks(cmd *cobra.Command) error {
231253
return nil
232254
}
233255

234-
preToolUse, ok := hooks["PreToolUse"].([]any)
235-
if !ok {
236-
fmt.Fprintln(cmd.OutOrStdout(), "No PreToolUse hooks found. Nothing to remove.")
237-
return nil
256+
var removedCount int
257+
258+
// Remove from PreToolUse
259+
if preToolUse, ok := hooks["PreToolUse"].([]any); ok {
260+
var kept []any
261+
for _, h := range preToolUse {
262+
if m, ok := h.(map[string]any); ok && hasRampartInMatcher(m) {
263+
removedCount++
264+
matcher, _ := m["matcher"].(string)
265+
fmt.Fprintf(cmd.OutOrStdout(), " Removed PreToolUse hook: matcher=%s\n", matcher)
266+
continue
267+
}
268+
kept = append(kept, h)
269+
}
270+
if len(kept) == 0 {
271+
delete(hooks, "PreToolUse")
272+
} else {
273+
hooks["PreToolUse"] = kept
274+
}
238275
}
239276

240-
var kept []any
241-
var removedCount int
242-
for _, h := range preToolUse {
243-
if m, ok := h.(map[string]any); ok && hasRampartInMatcher(m) {
244-
removedCount++
245-
matcher, _ := m["matcher"].(string)
246-
fmt.Fprintf(cmd.OutOrStdout(), " Removed hook: matcher=%s\n", matcher)
247-
continue
277+
// Remove from PostToolUseFailure
278+
if postToolUseFailure, ok := hooks["PostToolUseFailure"].([]any); ok {
279+
var kept []any
280+
for _, h := range postToolUseFailure {
281+
if m, ok := h.(map[string]any); ok && hasRampartInMatcher(m) {
282+
removedCount++
283+
matcher, _ := m["matcher"].(string)
284+
fmt.Fprintf(cmd.OutOrStdout(), " Removed PostToolUseFailure hook: matcher=%s\n", matcher)
285+
continue
286+
}
287+
kept = append(kept, h)
288+
}
289+
if len(kept) == 0 {
290+
delete(hooks, "PostToolUseFailure")
291+
} else {
292+
hooks["PostToolUseFailure"] = kept
248293
}
249-
kept = append(kept, h)
250294
}
251295

252296
if removedCount == 0 {
253297
fmt.Fprintln(cmd.OutOrStdout(), "No Rampart hooks found in Claude Code settings. Nothing to remove.")
254298
return nil
255299
}
256300

257-
// Clean up empty structures
258-
if len(kept) == 0 {
259-
delete(hooks, "PreToolUse")
260-
} else {
261-
hooks["PreToolUse"] = kept
262-
}
263301
if len(hooks) == 0 {
264302
delete(settings, "hooks")
265303
} else {
@@ -735,11 +773,32 @@ func hasRampartHook(settings claudeSettings) bool {
735773
if !ok {
736774
return false
737775
}
776+
777+
// Check PreToolUse
738778
preToolUse, ok := hooks["PreToolUse"].([]any)
739779
if !ok {
740780
return false
741781
}
782+
hasPreToolUse := false
742783
for _, h := range preToolUse {
784+
if m, ok := h.(map[string]any); ok {
785+
if hasRampartInMatcher(m) {
786+
hasPreToolUse = true
787+
break
788+
}
789+
}
790+
}
791+
if !hasPreToolUse {
792+
return false
793+
}
794+
795+
// Also require PostToolUseFailure to be registered; if it's missing,
796+
// return false so setup re-runs and adds it (dedup handles PreToolUse).
797+
postToolUseFailure, ok := hooks["PostToolUseFailure"].([]any)
798+
if !ok {
799+
return false
800+
}
801+
for _, h := range postToolUseFailure {
743802
if m, ok := h.(map[string]any); ok {
744803
if hasRampartInMatcher(m) {
745804
return true

0 commit comments

Comments
 (0)