Skip to content

Commit 9be540a

Browse files
authored
release: v0.6.5 — action:ask native prompt + Windows fixes (#125)
* fix(lint): add call_count to valid condition fields call_count was added to the engine in v0.4.8 but the linter's validConditionFields map was never updated. Any policy using call_count (including standard.yaml's rate-limit-fetch rule) would get a lint error from rampart doctor and rampart policy lint. Adds regression test. * fix(bench): embed corpus in binary, use it when --corpus not specified rampart bench with no arguments looked for bench/corpus.yaml relative to the current working directory. This always fails for installed users (go install, binary download) who are not in the repo root. Fix: embed corpus.yaml into the binary via go:embed. When --corpus is not explicitly set, use the embedded bytes directly. --corpus still accepts a custom file path as before. Output shows 'Corpus: built-in' when using embedded corpus. Adds regression test. * fix: four bugs found in post-release testing 1. audit: expandHome missing from listAuditFiles and listAnchorFiles All rampart audit subcommands (tail/verify/stats/search/replay) crashed with the default --audit-dir because ~ was never expanded. Fixed in the shared helpers so all paths are covered. 2. bench: approval-gated coverage always 0% require_approval expected corpus entries were never included in DenyTotal/ApprovalGated accounting. Coverage math now includes both deny and require_approval expected entries. 3. status: require_approval and webhook decisions dropped from today stats Both action types were silently skipped in todayEvents() switch. Now counted alongside deny (they are blocking decisions). 4. generate: verbose null/empty fields in output YAML Added omitempty to Policy.Priority, Enabled, Match.Agent, Match.Session, and all Condition slice/bool fields. Marshaling-only change — parsing of existing policies is unaffected. * chore: CHANGELOG for v0.4.10 * fix(upgrade): refresh policies even when already on latest version rampart upgrade returned early when current == target, skipping the policy update step. Users who upgraded binaries manually or who were already on the latest version never got policy improvements from newer releases without knowing to run rampart init --force separately. Now always runs upgradeStandardPolicies unless --no-policy-update is set. Adds regression test. * chore: CHANGELOG for v0.4.11 * fix(serve): read and persist token across restarts rampart serve --background generated a fresh random token on every start, ignoring ~/.rampart/token and never writing to it. Only the systemd/launchd install path (serve install) called resolveServiceToken, which reads the persisted file. The foreground serve path only checked RAMPART_TOKEN env. Fix: before creating the proxy, read the persisted token (RAMPART_TOKEN > ~/.rampart/token > generate new). After the listener is bound, write the token back to ~/.rampart/token so it survives restarts. Adds regression test: TestServeReadsAndPersistsToken — starts serve with a pre-written known token, verifies the same token is used and re-persisted. * chore: CHANGELOG for v0.4.12 * feat: e2e test suite + policy hardening - tests/e2e.yaml: 31-case test suite covering allow/deny/require_approval/watch across destructive cmds, FP regression, credentials, network exfil, env injection. Run: rampart test tests/e2e.yaml (uses installed policy) or: rampart test --config policies/standard.yaml tests/e2e.yaml (repo policy) - policies/standard.yaml: three policy gaps fixed: * block-credential-access: /etc/shadow, /etc/passwd, /etc/sudoers, /etc/gshadow now blocked for the read tool (were only blocked via exec patterns) * block-credential-commands: scp/rsync of private keys now blocked via combined command_contains('.ssh/id_') + command_matches('scp *'|'rsync *') * block-credential-commands: .pub exclusion added to SSH key rule to fix regression introduced by the scp rule split - internal/engine/testrunner.go: expandHome for policy: path in test YAML (tilde was resolved relative to test file dir, not home directory) - cmd/rampart/cli/test_cmd.go: --config flag now overrides policy: in test YAML, allowing 'rampart test --config dev-policy.yaml tests/e2e.yaml' workflows * fix: clean up policy rules and e2e tests - policies/standard.yaml: properly split block-credential-commands into 3 rules (ssh key cmds with .pub exclusion, scp/rsync transfer — dropped as FP-prone, aws/git/etc creds), add /etc/shadow|passwd|sudoers to read-tool block - tests/e2e.yaml: 30 cases, all passing — removed scp test (gap noted) - test_cmd.go: --config overrides policy: in test YAML - testrunner.go: expandHome for policy path * fix: SSH key policy edge cases + e2e tests - Narrow cat/head/tail patterns from /**/.ssh/** to /**/.ssh/id_* (too broad) - Add matching exclusion patterns for .pub files (cat **/.ssh/*.pub etc) - Add dedicated scp/rsync exfil rule with proper glob patterns - *scp*/.ssh/id_* matches exfil, excludes scp -i (auth) - *rsync*/.ssh/id_* matches exfil - E2E tests: 36 cases including scp/rsync edge cases All 36 e2e tests pass. All Go unit tests pass. * fix: harden SSH key policy (reviewer feedback) Fixes found by automated code review: Security fixes: - Remove -i flag exclusion from scp/rsync rule (prevents dual-key bypass: 'scp -i auth_key exfil_key remote:' was incorrectly allowed) - Add sftp to blocked transfer commands - Add mv, xxd, hexdump, od, strings to blocked read commands - Use *mv* pattern (leading wildcard) so mv with dest arg matches Tradeoff: - scp -i auth usage now blocked (security > convenience) - Users needing -i can use ssh-agent or local policy override Test updates: - Updated scp -i test expectation (now intentionally blocked) - All 36 e2e tests pass - All Go unit tests pass * chore: update CHANGELOG with security hardening details * feat: block agent self-modification of policy Add block-self-modification rule to standard.yaml that prevents AI agents from running rampart allow/block/rules/policy commands. These commands must be run by humans, not agents. This prevents the attack where an agent sees 'run rampart allow' in a denial message and tries to bypass its own restrictions. Tests: 4 new e2e tests, 40/40 passing * fix: add rate limiting to reload endpoint (1s cooldown) * fix: handle sudo/env wrappers in dangerous command detection * fix: URL detection, --tool override, atomic file writes * fix: use strconv.Atoi for strict int parsing, add flag mutual exclusion * docs: CHANGELOG and docs for v0.5.0 allow/block/rules/reload - CHANGELOG.md: add v0.5.0 release entry with Added/Changed/Fixed sections - docs-site/getting-started/quickstart.md: add Customize Your Rules section with allow/block/rules quick start and self-modification protection note - docs-site/features/policy-engine.md: add Custom Rules section documenting custom.yaml, allow/block, scopes, denial suggestions, self-mod protection, and the /v1/policy/reload API endpoint - docs-site/guides/customizing-policy.md: new full guide covering patterns, scopes, flags reference, team sharing, denial workflow, and self-mod protection - mkdocs.yml: add Customizing Policy to both Security Guides nav sections * feat: visual polish for status, doctor, upgrade, and serve commands - status: Boxed dashboard with progress bar, live server detection, and allow/deny/pending stats (lipgloss-powered, color + no-color safe) - doctor: Structured 'Try this:' hints on failures/warnings, embedded in messages with hintSep so JSON output gets a 'hint' field too - upgrade: Animated spinner during download and install phases; better permission error with sudo hint - serve: Startup spinner with clean 'Rampart ready' message and emoji-labeled dashboard/token lines - New spinner.go: lightweight braille-dot spinner (no bubbles dep), degrades to plain text on non-TTY outputs - Tests updated to match new status box output format * feat: add policy generate presets + rules tests - rampart policy generate preset: Interactive wizard with 4 presets (coding-agent, research-agent, ci-agent, devops-agent) - Uses charmbracelet/huh for interactive forms - 20 tests for policy generate, 26 tests for rules command - Flags: --preset, --dest, --force, --print * test: add internal/generate package tests * fix: improve command vs path detection for patterns like 'go build ./...' - Add commonCommands map to detect known executables - 'go build ./...' now correctly detected as exec, not path - Handle './...' Go package patterns specially - Add comprehensive tests for command/path detection * fix: maintain global indices when filtering rules list When using --global or --project filters with 'rampart rules', the displayed indices now match the global index used by 'rampart rules remove'. This prevents confusion where index 1 in a filtered view would remove a different rule than expected. * docs: add allow/block/rules to README, CLI reference, and homepage FAQ - README: Add 'Customizing rules' section showing allow/block workflow - CLI reference: Add full documentation for allow, block, rules, policy generate preset - Homepage: Update FAQ to mention 'rampart allow' command * ux: improve error messages and add denial suggestions to test command - allow/block: Show examples when pattern is missing - allow/block: Suggest quoting when multiple args are given - rules remove: Show usage example when index is missing - test: Show 'rampart allow' suggestions when command is denied Follows Atlassian's CLI design principles: - Craft human-readable error messages - Suggest the next best step * fix: address all review feedback for v0.5.0 Bug fixes: 1. rules remove/reset now actually call reload API (was lying about reload) 2. allow no longer prints duplicate rule summary after save 3. doctor says 'X warnings' instead of 'No issues found' when warnings exist 4. Hook binary check deduplicates (no more 4 identical lines) 5. FlattenRules uses local variable to prevent tool type bleeding UX improvements: 6. Dangerous commands (rm -rf /, curl|bash) show warning instead of allow suggestion 7. Duplicate pattern detection warns instead of silently adding 8. Multi-policy matches (>3) now show as bulleted list instead of long comma line All 18 test packages passing. * fix: address all v0.5.0 review bugs Bug fixes: - Bug 1: ./script.sh now detected as exec (not path) for common script extensions - Bug 2: --tool flag validates against exec/read/write/edit, rejects 'path' - Bug 3: isExtremelyDangerous() now catches rm -rf ~ and ~/*, not just / - Bug 4: rules remove -1 shows helpful error instead of 'unknown flag' - Bug 5: --message displayed in preview, rules list, and JSON output - Bug 6: dd/mkfs targeting /dev/* now suppresses allow suggestions - Bug 7: rampart test shows 👤 APPROVAL for require_approval (was showing ✅ ALLOW) - Bug 8: Sensitive paths (/etc/shadow, ~/.ssh/id_rsa) show warning instead of allow suggestion Updated test expectations for new sensitive path handling. * fix: address review agent findings (UX + security) UX Fix: - Add 'rampart rules list' as alias for 'rampart rules' (users expect list subcommand) Security Fixes: - Expand self-modification protection to block path-based invocations: - */rampart allow * (absolute paths like /usr/local/bin/rampart) - ./rampart allow * (relative paths) - rampart --* allow * (global flags before subcommand) - Improve isExtremelyDangerous() to catch shell escape bypasses: - Quoted binaries: 'rm' -rf / - Flag reordering: rm -fr / - Long flags: rm --recursive --force / - Add normalizeForDangerCheck() to strip quotes/escapes - Add containsRmRecursiveForce() for robust flag detection - Add dangerous pattern suppression for: - base64 -d | bash (decode-and-execute) - <(curl ...) (process substitution) Tests: - Add TestMatchGlob_SelfModificationPatterns for bypass coverage * fix: address Opus review findings Security fixes: - Add command_contains for self-modification protection (HIGH - blocks bash -c wrapper bypass) - Add shell wrapper patterns (bash -c, sh -c) for destructive commands - Block rm -rf . and rm -rf .. (current/parent directory wipe) UX improvements: - Add rm -rf ., .. to isExtremelyDangerous() (suppresses allow suggestions) - Update standard.yaml header to accurately describe matching capabilities The command_contains approach is now the primary defense for self-modification protection - simple substring matching catches ALL wrapper techniques without needing complex glob patterns. * fix: address remaining Opus review findings Security/UX improvements: - Add warnIfOverlyPermissive() - warns before adding '*', '**', '/*' patterns - Make dd blocking more specific (of=/dev/sd*, not all dd if=*) - Change lint severity for >2 ** segments from warning to error (pattern will silently fail at runtime, users must know) This addresses: - LOW: dd if=* was overly broad → now targets block devices only - MED: No warning for permissive patterns → now warns with confirmation - MED: >2 ** lint was warning → now error (matches runtime rejection) * docs: add security fixes to CHANGELOG for v0.5.0 * test: add e2e tests for shell wrapper bypass protection Adds 5 new tests for v0.5.0 security fixes: - bash -c 'rampart allow' (shell wrapper self-mod bypass) - sh -c 'rm -rf /' (shell wrapper destructive bypass) - rm -rf . (current directory wipe) - rm -rf .. (parent directory wipe) - bash -c 'mkfs /dev/sda' (shell wrapper format bypass) Total: 45 e2e tests * docs: update for v0.5.0 release - Add CHANGELOG comparison links for v0.4.x and v0.5.0 - Add self-modification protection to README security section - Add self-modification section to threat-model.md - Update customizing-policy.md with command_contains example - Fix [Unreleased] link to point to v0.5.0 * docs: update known limitations for v0.5.0 shell wrapper fixes - SECURITY.md: Update 'pattern evasion' limitation - shell wrappers now covered - ARCHITECTURE.md: Update 'pattern matching is bypassable' - note normalization and command_contains * fix: critical bugs found in codebase audit 1. Fix approve/deny token resolution - now checks ~/.rampart/token file Previously these commands only checked --token flag and RAMPART_TOKEN env, ignoring the persisted token file that 'rampart serve install' creates. Users got 'token required' errors even when token existed. 2. Remove stale .goreleaser.yaml (v1 format) Both .goreleaser.yaml and .goreleaser.yml existed. GoReleaser checks .goreleaser.yaml first, which was the old v1 config missing Homebrew tap and using wrong ldflags. Releases could silently use wrong config. 3. Run go mod tidy charmbracelet/huh marked as direct dependency (was indirect). 4. Update tests to use temp HOME dir Prevents tests from picking up real ~/.rampart/token file. * chore: remove dead code and fix staticcheck warnings - Remove unused functions: pollApproval, serveReachable, policiesLoadedFromEvents - Remove unused test scaffolding: mockEngine, makeToolsListJSON - Remove unused readTools categorization (never generated policy) - Remove unused sink field from SDK struct - Fix deprecated prometheus.NewGoCollector -> collectors.NewGoCollector - Fix orphan imports (net/http, time) after serveReachable removal - Fix unused variable warnings (globalPath, sevStr) staticcheck clean except intentional style choices * feat: add rampart setup cursor and rampart setup windsurf MCP-based integration for Cursor and Windsurf AI editors. - Wraps existing MCP servers in config with 'rampart mcp --' - Auto-detects already-wrapped servers (skips or re-wraps with --force) - Supports --remove to restore original config - Backs up original config before modification - 6 test cases covering wrap/unwrap/edge cases Cursor config: ~/.cursor/mcp.json Windsurf config: ~/.codeium/windsurf/mcp_config.json * docs: add Cursor and Windsurf to README integration table * fix: remove hardcoded config path from Cline hooks The generated Cline hook scripts were hardcoding ~/.rampart/policies/standard.yaml which breaks when users have custom config locations. Now uses rampart's default config discovery (same as Claude Code hooks): 1. rampart.yaml in current directory 2. ~/.rampart/config.yaml 3. Built-in defaults * ci: add macOS and Windows test runners - Tests now run on ubuntu-latest, macos-latest, windows-latest - Use 'go run' for policy checks (cross-platform compatible) - fail-fast: false so all platforms complete even if one fails - Coverage artifacts named per-OS to avoid collisions All runners are FREE for public repos. * fix: upgrade tests use dynamic platform, disable Windows CI temporarily - Add testArchiveName() helper to generate platform-specific archive names - Fixes macOS CI failures (checksum for darwin_arm64 not found) - Temporarily exclude Windows from CI matrix (test isolation issues with global ~/.rampart/) - TODO: Fix Windows test isolation and re-enable * fix: Windows test isolation with testSetHome helper - Add testSetHome(t, dir) that sets both HOME and USERPROFILE - On Windows, os.UserHomeDir() checks USERPROFILE first, not HOME - Replace all t.Setenv("HOME", ...) with testSetHome() - Skip Unix file permission checks on Windows - Use filepath.FromSlash for cross-platform path assertions - Re-enable Windows in CI matrix * fix: more Windows test isolation fixes - Convert remaining t.Setenv("HOME", ...) to testSetHome() - Affects: rules, serve, setup, token, upgrade, watch tests * fix: skip Unix-specific tests on Windows - Skip file permission tests (Unix 0o600 not applicable) - Skip upgrade tests (Windows not in upgradePlatform) - Skip shell shim tests (bash not available) - Skip Unix path tests (/etc/shadow, path traversal) * fix: skip remaining upgrade tests on Windows Add skipOnWindows to: - TestNewUpgradeCmdSuccessNoServe - TestNewUpgradeCmdSystemdRestart - TestNewUpgradeCmdSystemdTakesPriorityOverPID * fix: skip Unix path tests in internal/ packages on Windows - Skip filesystem interceptor tests (Unix paths in fixtures) - Skip e2e standard policy tests (Unix paths in standard.yaml) - Skip testrunner tests (Unix paths in test cases) - Skip suggestions test (Unix paths in fixtures) * fix: quote coverprofile flag for Windows PowerShell PowerShell was interpreting 'coverage.out' as a package name * feat: cross-platform path matching for Windows Claude Code support Normalize backslashes to forward slashes in MatchGlob so that policy patterns like '**/.ssh/id_*' match Windows paths like 'C:\Users\Trevor\.ssh\id_rsa'. This is critical for Windows users using Claude Code with Rampart — without this fix, path-based policies would never match on Windows. Added tests for Windows path patterns to verify cross-platform matching. * ci: skip benchmarks on Windows (PowerShell parsing issues) * fix: address review findings for v0.6.0 Windows release Security fixes: - Move backslash normalization to cleanPaths() before filepath.Clean (fixes audit integrity issue on Unix with paths like /home/user\../etc) - Add Windows token file security warning (os.Chmod is no-op on Windows) - Add backslash injection test cases Cursor/Windsurf fixes: - Fix hardcoded Cursor docs URL shown to Windsurf users - Add URL field to mcpServer for SSE-based servers - Skip SSE servers with warning (cannot be wrapped) - Only create backup if none exists (preserve true original) - Remove dead init() function - Update setup --help to list Cursor and Windsurf * remove cursor/windsurf setup commands (MCP-only = false security) Cursor and Windsurf have native built-in tools (file read/write, terminal) that don't go through MCP. The setup commands only wrapped MCP servers, giving users false confidence their agents were protected when 90%+ of tool calls were unmonitored. Removed: - rampart setup cursor - rampart setup windsurf - cursor/windsurf auto-detection in quickstart - All related tests and docs Users wanting MCP-only protection can still use 'rampart mcp --' manually. Focus remains on agents with real hook APIs (Claude Code, Cline) where we can intercept all tool calls. Also removed dead 'go generate ./...' from goreleaser (no go:generate directives exist in the codebase). * add Windows PowerShell installer - install.ps1: one-liner install for Windows users - Downloads latest release from GitHub - Extracts to ~/.rampart/bin, adds to PATH - Auto-detects Claude Code and offers to set up hooks - No admin rights required Usage: irm https://rampart.sh/install.ps1 | iex * docs: complete Windows support documentation - docs-site/getting-started/installation.md: Add Windows tab with PowerShell installer, document Windows limitations table - docs-site/integrations/cursor.md: Add warning that only MCP servers are protected, not Cursor's native built-in tools - docs/guides/windows.md: Standalone Windows setup guide - docs/install.ps1: Copy for GitHub Pages hosting at rampart.sh/install.ps1 * implement Windows ACLs for token file security - token_windows.go: Proper Windows ACL implementation using advapi32.dll Sets owner-only GENERIC_ALL access, removes all other permissions - token_unix.go: Standard chmod 0600/0700 for Unix platforms - Remove 'future version' comments - it's implemented now - Update Windows service messages to be more helpful (suggest NSSM/Task Scheduler) - Update docs to remove 'planned for future' language * docs: remove stale cursor setup command from agent-install guide * security: replace unsafe Windows ACL syscalls with icacls The previous implementation used hand-rolled Windows API calls via advapi32.dll with the unsafe package. This had risks: - Struct alignment must exactly match Windows C structures - Memory management with LocalFree - No easy way to test correctness New implementation uses icacls.exe (built into Windows): - icacls path /inheritance:r /grant:r USERNAME:F - Removes inherited permissions - Grants full control only to current user - Well-tested Windows utility - No unsafe package needed - Graceful fallback if icacls fails -108 lines of complex syscall code, +23 lines of simple exec. * docs: clarify that rampart serve is optional for basic protection The hook evaluates policies locally — no server needed for allow/deny. Serve is only required for: - Live dashboard (rampart watch) - Approval flow (require_approval policies) - Centralized audit streaming Updated: - install.ps1: Simplified next steps, serve marked as optional - docs/guides/windows.md: Reframed serve as optional - docs-site/getting-started/installation.md: Changed note to success callout * feat: add cross-platform uninstall command rampart uninstall: - Removes hooks from Claude Code, Cline - Stops and removes systemd/launchd services - Removes from PATH (Windows: programmatic, Unix: prints instructions) - Removes shell shim if present - Prints final instructions to delete ~/.rampart Works on Windows, macOS, and Linux. Use --yes to skip prompts. * fix: stop running rampart serve process during uninstall - Windows: Use PowerShell to find and stop rampart serve processes - macOS/Linux: Use pkill -f 'rampart serve' before service removal This ensures 'rampart uninstall' cleans up running processes on all platforms. * fix: clear existing install dir before extraction Fixes 'ExtractToFile: file already exists' error when reinstalling. Some PowerShell versions/configurations use .NET extraction methods that don't honor -Force overwrite flag. * fix: sync docs/install.ps1 with root * fix: better error handling for Windows permission issues - Detect and explain permission errors when removing existing install - Clean up partial install on extraction failure - Provide takeown/icacls recovery commands * fix: Windows installer UX improvements - Fix icacls command quoting in error message ($env:USERNAME:F → $($env:USERNAME):F) - Add cmd.exe rd fallback when PowerShell Remove-Item fails - Better formatting for recovery instructions * fix: Windows installer polish - Remove 'restart terminal' instruction (PATH refreshes in-session) - Always refresh session PATH (not just when adding) - Update uninstall instruction to use 'rampart uninstall' - Show try-it-now commands after install * docs: Windows troubleshooting for AV and permissions - Add SmartScreen/Defender/antivirus bypass instructions - Add permission error recovery steps - Update uninstall to use 'rampart uninstall' * feat: use wildcard matcher to intercept ALL Claude Code tools Previously we only hooked Bash, Read, Write|Edit. Now we use '.*' to catch all tools including Fetch, Task, and any future tools Claude adds. This ensures comprehensive coverage without needing to update Rampart when Anthropic adds new tool types. Users should run 'rampart setup claude-code --force' to upgrade their hooks to the new wildcard matcher. * feat: Windows installer detects upgrade and offers to refresh hooks On reinstall, if Rampart hooks already exist in Claude Code settings, prompts 'Update hooks to latest version?' and runs with --force. This ensures users get new hook features (like wildcard matcher) when upgrading Rampart. * security: fix audit findings from PR #119 review High: Add SHA256 checksum verification to Windows installer - Downloads checksums.txt from release and verifies downloaded zip - Fails with clear error on mismatch, warns if checksums unavailable Medium: Fix PowerShell single-quote injection in uninstall.go - Escape single quotes in PATH value before passing to PowerShell - Prevents injection via crafted PATH entries like C:\Users\O'Brien\ Low: Use os.Executable() instead of PATH lookup in uninstall - Prevents malicious 'rampart' binary earlier in PATH from executing Low: Improve upgrade detection regex in install.ps1 - Match specific 'rampart hook' command pattern, not just 'rampart' substring * fix: Windows path detection in upgrade regex and hook matcher - install.ps1: Add (?:\.exe)? to match both 'rampart hook' and 'rampart.exe hook' - setup.go hasRampartInMatcher: Handle Windows backslash paths and .exe extension Fixes regression where Windows installs weren't detected as upgrades. * fix: graceful shutdown closes SSE connections first SSE connections were blocking server shutdown, causing 'context deadline exceeded' errors on Ctrl+C. Now we close all SSE clients before shutting down the HTTP server. Also fixed potential double-close panic in unsubscribe by checking if channel still exists. * fix: add closed flag to sseHub + race-safe tests - Add 'closed bool' field to prevent post-Close subscriptions - subscribe() returns immediately-closed channel if hub is closed - broadcast() skips if hub is closed - Add 5 tests covering shutdown scenarios with -race flag Closes the race window between sse.Close() and srv.Shutdown(). * fix: installer stops rampart processes before upgrade - Try 'rampart serve stop' gracefully before deletion - Kill any remaining rampart processes - Add 500ms delay for Windows to release file handles This prevents 'Access Denied' errors during upgrade when serve is running. * fix: add 200ms delay on Windows shutdown for file handle release Windows Defender or the file indexer can briefly lock files after a process exits. Adding a small delay before serve returns gives the OS time to release handles, preventing 'Access Denied' errors on subsequent operations. * security: remove hardcoded token from openclaw-shim example Token is now auto-read from ~/.rampart/token at runtime. Thanks to Trevor for catching this in code review. Also enabled GitHub secret scanning + push protection on the repo. * feat(engine): add ActionAsk for native Claude Code permission prompt - Add ActionAsk constant to the Action enum (after ActionWebhook) - Update Action.String() to return "ask" for ActionAsk - Update ParseAction() to handle "ask" → ActionAsk - Update Rule.ParseAction() in policy.go to handle "ask" - Add "ask" to validActions map in lint.go - Add lint warning: action 'ask' is only supported for claude-code agent — warns when match.agent is not explicitly "claude-code" - Handle ActionAsk in engine.Evaluate() switch (priority: above watch, below require_approval; deny still wins) - Handle ActionAsk in evaluateResponsePolicies() for completeness - Add ask_test.go: ParseAction, Rule.ParseAction, and engine evaluation tests (matched rule, no-match default-deny, non-cc agent, mixed policy) - Add 5 lint test cases: valid scoped usage, unscoped warning, wildcard agent warning, other-agent warning, valid action check Implements Phase 1 (1.1, 1.2) of design doc: docs/design/issue-122-native-ask-prompt.md * feat(session): add session state package for ask prompt tracking * feat(hook): integrate ActionAsk with session state tracking - Add SessionID and ToolUseID fields to hookParseResult struct (extracted from Claude Code hook input json: session_id, tool_use_id) - Add sessionStateDir() helper returning ~/.rampart/session-state/ - Add background cleanup goroutine at RunE startup (24h TTL) - Handle ActionAsk in the decision switch: * RecordAsk() writes pending_asks[toolUseID] to session state file * Emits permissionDecision:"ask" to trigger native Claude Code dialog * Gracefully skips session write when session_id/tool_use_id absent - Handle PostToolUse approval observation: * Keyed on hook_event_name=="PostToolUse" + non-empty ToolUseID+SessionID * Calls ObserveApproval() which moves entry to session_approvals * Logs approval with tool/pattern/count; debug-logs non-ask PostToolUse * Best-effort: errors never block the hook - Switch stdin source from os.Stdin to cmd.InOrStdin() for testability - Add hook_ask_test.go with 5 tests: * TestParseClaudeCodeInput_SessionAndToolUseID * TestParseClaudeCodeInput_SessionIDEmpty * TestHookActionAsk_WritesSessionState (full RunE, verifies session file) * TestHookPostToolUse_ObservesApproval (full RunE, pre-seeds + observes) * TestHookActionAsk_NoSessionID (graceful no-op without session_id) Closes phase 1 hook integration for issue #122. * security: fix path traversal and trim loop in session state - Add validateSessionID() to reject path traversal attacks via session IDs containing '../', '/', '\', spaces, or other invalid characters - Fix 64KB size limit: change single-trim 'if' to loop that trims until actually under limit - Add 12 path traversal test cases Fixes security findings from code review. * fix(init): create policies even when config exists Previously 'rampart init' would error and exit if rampart.yaml already existed, before creating the policies directory. Now: - If config exists: skip config write but still create policies - If policies exist: skip policy write - Print clear message about what was created/skipped This fixes the UX issue where 'rampart doctor' shows policies as missing even after running init, because an existing config caused init to bail out early. Fixes #124 * fix(hook): don't print to stderr for ask decisions Claude Code interprets any stderr output as a hook error, causing it to show 'PreToolUse:Bash hook error' and fall back to allowing the command. For 'ask' decisions, the reason is already conveyed via the JSON response (PermissionDecisionReason field), so stderr output is unnecessary and actually breaks the native prompt integration. Keep stderr output for deny/block since those are terminal states where the user needs to see why their command was blocked. * fix(hook): remove slog.Warn calls that bypass logger config The mapClaudeCodeTool and mapClineTool functions used slog.Warn directly instead of the configured logger. This wrote warnings to stderr before we could suppress them, causing Claude Code to show 'hook error'. Unknown tool names are handled gracefully by returning 'unknown'. * fix(hook): use Debug instead of Warn for RecordAsk failures Claude Code treats ANY stderr output as a hook error for ask decisions. RecordAsk is best-effort anyway - the ask prompt still shows even if session state recording fails. This fixes the 'PreToolUse:Bash hook error' when using action: ask. Root cause: Manual tests don't include session_id/tool_use_id, but real Claude Code invocations do. When present, RecordAsk is called, and if it fails for any reason (path issues, permissions, etc.), logger.Warn wrote to stderr which broke the Claude Code integration. * fix(session): downgrade all Warn logs to Debug for Claude Code compatibility The session manager's Cleanup goroutine and trim logic had Warn-level logs that wrote to stderr. Claude Code treats any stderr output as a hook error for ask decisions. Changed all Warn to Debug in session package: - State file size limit trimming - Cleanup cannot read file - Cleanup removing unparseable file - Cleanup remove failed Session operations are best-effort; failures should not break the hook. * fix(setup): convert Windows paths to Git Bash format for Claude Code hooks Claude Code on Windows runs hooks through Git Bash, which doesn't understand Windows backslash paths. This caused 'command not found' errors because paths like 'C:\Users\trev\.rampart\bin\rampart.exe' were mangled to 'C:Userstrev.rampartbinrampart.exe'. Added toGitBashPath() function that converts Windows paths to Git Bash compatible format: C:\path\to\file -> /c/path/to/file This fix ensures 'rampart setup claude-code' works correctly on: - Windows (paths converted to Git Bash format) - macOS (paths unchanged) - Linux (paths unchanged) Root cause discovered via Claude Code --debug hooks, which showed: 'Hook PreToolUse:Bash error: /usr/bin/bash: line 1: C:Userstrev... command not found' * feat(hook): smart require_approval fallback + Windows path fixes Three improvements for better Windows support and require_approval UX: 1. require_approval → native ask fallback When 'rampart serve' isn't running, require_approval policies now fall back to native Claude Code ask prompts instead of hanging forever. The hook checks /healthz with a 150ms timeout before attempting dashboard approval. This fixes the issue where the token file persists after serve stops, causing require_approval to try a dead server. 2. toGitBashPath() function (fixes incomplete commit 20fff79) Claude Code on Windows runs hooks through Git Bash, which doesn't understand Windows backslash paths. This function converts C:\Users\trev\.rampart\bin\rampart.exe to /c/Users/trev/.rampart/bin/rampart.exe 3. Windows-specific PATH warning 'rampart setup claude-code' now shows Windows-appropriate instructions for adding rampart to PATH instead of Linux 'sudo ln -sf' commands. Fixes: issue where require_approval blocks forever when serve not running Fixes: Claude Code hooks failing on Windows with 'command not found' Fixes: confusing Linux PATH fix shown on Windows * hook: fail closed for ask in bypassPermissions mode * engine: lint misplaced top-level policy action * docs: add action ask policy example * engine: add expect ask policy test case * docs: v0.6.5 CHANGELOG, native-ask guide, bypass mode note * Revert "hook: fail closed for ask in bypassPermissions mode" This reverts commit f965a00. * docs: correct action:ask bypass mode behavior — prompt works in --dangerously-skip-permissions
1 parent af61d79 commit 9be540a

24 files changed

+3141
-58
lines changed

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.6.5] - 2026-02-27
11+
12+
### Added
13+
14+
- **`action: ask`** — Native Claude Code inline permission prompt (issue #122). Policy rules can now use `action: ask` to trigger Claude Code's built-in approval dialog instead of blocking execution. The user sees an inline prompt with the command details, explain mode (`ctrl+e`), and yes/no choice without leaving the session.
15+
- **Smart `require_approval` fallback** — When `rampart serve` is not running, `require_approval` rules automatically fall back to the native ask prompt instead of hanging indefinitely.
16+
- **Session state tracking**`action: ask` decisions are persisted to `~/.rampart/session-state/` and correlated across PreToolUse/PostToolUse hook invocations for accurate approval tracking.
17+
- **`rampart uninstall`** — Cross-platform command to remove hooks, stop serve processes, and clean up PATH entries.
18+
- **`docs/guides/native-ask.md`** — User guide for `action: ask` with correct YAML syntax, use cases, and limitations.
19+
20+
### Security
21+
22+
- **Session state path traversal protection**`validateSessionID` rejects session IDs containing path traversal characters.
23+
- **Removed hardcoded token** from `contrib/openclaw-shim.sh` (was committed as an example; now reads from `~/.rampart/token`).
24+
25+
### Fixed
26+
27+
- **Windows: Claude Code hooks use Git Bash path format** — Hooks were silently ignored because `C:\Users\trev\.rampart\bin\rampart.exe` backslash paths were mangled by Git Bash. `rampart setup claude-code` now writes `/c/Users/trev/.rampart/bin/rampart.exe` format.
28+
- **Hook stderr output caused silent allow** — Claude Code treats any hook stderr as a hook error and defaults to allow. Rampart no longer writes to stderr for ask decisions or session manager warnings.
29+
- **Session manager logs** — Downgraded from `Warn` to `Debug` to prevent unintended stderr output during hook execution.
30+
- **`rampart init` now creates policies when config already exists** — Previously skipped policy creation if `~/.rampart/config.yaml` was present.
31+
- **Graceful SSE shutdown**`rampart serve` now closes SSE connections before exiting, fixing Ctrl+C hangs with "context deadline exceeded" errors.
32+
- **Windows shutdown file handle delay** — Added 200ms delay on Windows exit to give the OS time to release file handles before process termination.
33+
34+
### Improved
35+
36+
- **Wildcard hook matcher**`rampart setup claude-code` now installs a `.*` matcher that intercepts ALL Claude Code tools (Bash, Read, Write, Edit, Fetch, Task, and future tools). Previously only specific tool names were hooked.
37+
- **Lint: misplaced `action` field detection**`rampart policy lint` now detects when `action:` is written at the policy level (sibling of `name`, `match`, `rules`) instead of inside a `rules:` entry, and emits a helpful "did you mean to put this under `rules:`?" message.
38+
- **Windows installer upgrade detection** — Installer detects existing installations and offers to refresh Claude Code hooks with `--force`.
39+
1040
## [0.5.0] - 2026-02-24
1141

1242
### Added

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,21 @@ Use `command_contains` for substring matching (case-insensitive, v0.4.4+):
336336

337337
> `command_contains` uses substring matching (case-insensitive). `command_matches` uses glob patterns.
338338

339+
Use `action: ask` to trigger Claude Code's native approval prompt:
340+
341+
```yaml
342+
- name: ask-before-sudo
343+
match:
344+
agent: ["claude-code"]
345+
tool: ["exec"]
346+
rules:
347+
- action: ask
348+
when:
349+
command_contains:
350+
- "sudo "
351+
message: "This command needs your approval"
352+
```
353+
339354
**Evaluation:** Deny always wins. Lower priority number = evaluated first. Four actions: `deny`, `require_approval`, `watch`, `allow`. (`log` is a deprecated alias for `watch` — update your policies if you use it.)
340355

341356
### Project-local policies

cmd/rampart/cli/cli_test.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,24 @@ func TestInitCreatesFile(t *testing.T) {
5959
assert.Equal(t, "1", parsed["version"])
6060
}
6161

62-
func TestInitRefusesOverwrite(t *testing.T) {
62+
func TestInitSkipsExistingConfig(t *testing.T) {
6363
dir := t.TempDir()
6464
testSetHome(t, dir)
6565
configPath := filepath.Join(dir, "rampart.yaml")
6666
require.NoError(t, os.WriteFile(configPath, []byte("existing: true\n"), 0o644))
6767

68-
_, _, err := runCLI(t, "--config", configPath, "init")
69-
require.Error(t, err)
70-
assert.Contains(t, err.Error(), "already exists")
68+
// Should succeed - creates policies even if config exists
69+
stdout, _, err := runCLI(t, "--config", configPath, "init")
70+
require.NoError(t, err)
71+
assert.Contains(t, stdout, "config already exists")
72+
73+
// Config should be unchanged
74+
data, _ := os.ReadFile(configPath)
75+
assert.Contains(t, string(data), "existing: true")
76+
77+
// But policies should be created
78+
policyPath := filepath.Join(dir, ".rampart", "policies", "standard.yaml")
79+
assert.FileExists(t, policyPath)
7180
}
7281

7382
func TestInitForceOverwrite(t *testing.T) {

cmd/rampart/cli/coverage_boost3_test.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,22 @@ import (
1616

1717
func TestNewInitCmd_AlreadyExists(t *testing.T) {
1818
dir := t.TempDir()
19+
testSetHome(t, dir)
1920
p := filepath.Join(dir, "rampart.yaml")
2021
os.WriteFile(p, []byte("existing"), 0o644)
2122

22-
root := NewRootCmd(context.Background(), &bytes.Buffer{}, &bytes.Buffer{})
23+
stdout := &bytes.Buffer{}
24+
root := NewRootCmd(context.Background(), stdout, &bytes.Buffer{})
2325
root.SetArgs([]string{"init", "--config", p})
2426
err := root.Execute()
25-
if err == nil {
26-
t.Error("expected error for existing file without --force")
27+
// Should succeed now - init creates policies even if config exists
28+
if err != nil {
29+
t.Errorf("expected no error, got %v", err)
30+
}
31+
// Config should be unchanged
32+
data, _ := os.ReadFile(p)
33+
if string(data) != "existing" {
34+
t.Errorf("config was modified, expected 'existing' got %q", string(data))
2735
}
2836
}
2937

cmd/rampart/cli/hook.go

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/json"
1010
"fmt"
1111
"log/slog"
12+
"net/http"
1213
"os"
1314
"os/exec"
1415
"path/filepath"
@@ -18,6 +19,7 @@ import (
1819

1920
"github.com/peg/rampart/internal/audit"
2021
"github.com/peg/rampart/internal/engine"
22+
"github.com/peg/rampart/internal/session"
2123
"github.com/spf13/cobra"
2224
)
2325

@@ -89,6 +91,8 @@ type hookParseResult struct {
8991
Response string // non-empty for PostToolUse events
9092
RunID string // run ID derived from session_id (or env overrides)
9193
HookEventName string // e.g. "PreToolUse", "PostToolUse", "PostToolUseFailure"
94+
SessionID string // raw session_id from Claude Code input (for session state)
95+
ToolUseID string // tool_use_id from Claude Code input (for ask correlation)
9296
}
9397

9498
// gitContext holds the git repository context for the current working directory.
@@ -154,6 +158,39 @@ func gitRevParseTopLevel() (string, error) {
154158
return strings.TrimSpace(string(out)), nil
155159
}
156160

161+
// sessionStateDir returns the directory used for per-session state files.
162+
// The directory is ~/.rampart/session-state/ by default.
163+
func sessionStateDir() string {
164+
home, err := os.UserHomeDir()
165+
if err != nil {
166+
return ""
167+
}
168+
return filepath.Join(home, ".rampart", "session-state")
169+
}
170+
171+
// isServeRunning checks if rampart serve is actually running by hitting the healthz endpoint.
172+
// Returns true if serve responds within the timeout, false otherwise.
173+
// This is used for require_approval fallback: if serve isn't running, we fall back to
174+
// native ask prompts instead of hanging on a dashboard approval that will never come.
175+
func isServeRunning(serveURL string) bool {
176+
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
177+
defer cancel()
178+
179+
healthURL := strings.TrimRight(serveURL, "/") + "/healthz"
180+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
181+
if err != nil {
182+
return false
183+
}
184+
185+
resp, err := http.DefaultClient.Do(req)
186+
if err != nil {
187+
return false
188+
}
189+
defer resp.Body.Close()
190+
191+
return resp.StatusCode == http.StatusOK
192+
}
193+
157194
func newHookCmd(opts *rootOptions) *cobra.Command {
158195
var auditDir string
159196
var mode string
@@ -245,6 +282,14 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
245282
}
246283
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))
247284

285+
// Cleanup stale session state files in the background (best-effort).
286+
// This runs once per hook invocation; typically fires every few seconds
287+
// during active sessions, which is sufficient to keep the directory clean.
288+
go func() {
289+
mgr := session.NewManager(sessionStateDir(), "", logger)
290+
_ = mgr.Cleanup(24 * time.Hour)
291+
}()
292+
248293
// Load policies
249294
policyPath, cleanupPolicy, err := resolveWrapPolicyPath(opts.configPath)
250295
if err != nil {
@@ -297,9 +342,9 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
297342

298343
switch format {
299344
case "claude-code":
300-
parsed, err = parseClaudeCodeInput(os.Stdin, logger)
345+
parsed, err = parseClaudeCodeInput(cmd.InOrStdin(), logger)
301346
case "cline":
302-
parsed, err = parseClineInput(os.Stdin, logger)
347+
parsed, err = parseClineInput(cmd.InOrStdin(), logger)
303348
default:
304349
// Should be unreachable — format is validated above.
305350
// Explicit default prevents a nil parsed pointer reaching
@@ -445,6 +490,31 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
445490
go sendNotification(config.Notify, call, decision, logger)
446491
}
447492

493+
// PostToolUse: observe approval for any pending ask entries in session state.
494+
// This fires when tool_response is present, indicating the user approved the ask.
495+
// The observation is best-effort — errors are logged but do not block the hook.
496+
if parsed.HookEventName == "PostToolUse" && parsed.ToolUseID != "" && parsed.SessionID != "" {
497+
sessionMgr := session.NewManager(sessionStateDir(), parsed.SessionID, logger)
498+
record, obsErr := sessionMgr.ObserveApproval(parsed.ToolUseID)
499+
if obsErr != nil {
500+
// Not found means this PostToolUse was not preceded by an ActionAsk —
501+
// that is normal (most tool calls are allowed/denied, not asked).
502+
logger.Debug("hook: PostToolUse observe approval (no pending ask or other issue)",
503+
"session_id", parsed.SessionID,
504+
"tool_use_id", parsed.ToolUseID,
505+
"error", obsErr,
506+
)
507+
} else {
508+
logger.Info("hook: observed approval for ask",
509+
"session_id", parsed.SessionID,
510+
"tool_use_id", parsed.ToolUseID,
511+
"tool", record.Tool,
512+
"pattern", record.Pattern,
513+
"approval_count", record.ApprovalCount,
514+
)
515+
}
516+
}
517+
448518
// Return decision
449519
cmdStr := extractCommand(call)
450520
if mode != "enforce" {
@@ -457,8 +527,30 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
457527
return outputHookResult(cmd, format, hookBlock, true, decision.Message, cmdStr, decision.Suggestions...)
458528
}
459529
return outputHookResult(cmd, format, hookDeny, false, decision.Message, cmdStr, decision.Suggestions...)
530+
case engine.ActionAsk:
531+
// Write pending ask to session state so PostToolUse can correlate the outcome.
532+
if parsed.SessionID != "" && parsed.ToolUseID != "" {
533+
sessionMgr := session.NewManager(sessionStateDir(), parsed.SessionID, logger)
534+
// Build a generalised pattern for the command (use command as-is for now;
535+
// Phase 2 will add pattern generalisation via engine.GeneralizePattern).
536+
cmdStr2 := extractCommand(call)
537+
policyName := ""
538+
if len(decision.MatchedPolicies) > 0 {
539+
policyName = decision.MatchedPolicies[0]
540+
}
541+
if err := sessionMgr.RecordAsk(parsed.ToolUseID, call.Tool, cmdStr2, cmdStr2, policyName, decision.Message); err != nil {
542+
// NOTE: Use Debug, not Warn. Claude Code treats ANY stderr as a hook error
543+
// for ask decisions. RecordAsk is best-effort anyway.
544+
logger.Debug("hook: failed to record ask in session state", "error", err)
545+
}
546+
}
547+
// Emit native ask prompt (Claude Code shows the 4-button dialog).
548+
return outputHookResult(cmd, format, hookAsk, false, decision.Message, cmdStr)
460549
case engine.ActionRequireApproval:
461-
if serveURL != "" {
550+
// Check if serve is actually running. The token file persists after serve stops,
551+
// so we can't rely on its presence. A quick health check confirms serve is up.
552+
// If serve isn't running, fall back to native ask prompts.
553+
if serveURL != "" && isServeRunning(serveURL) {
462554
approvalClient := &hookApprovalClient{
463555
serveURL: strings.TrimRight(serveURL, "/"),
464556
token: serveToken,
@@ -471,6 +563,9 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
471563
result := approvalClient.requestApprovalCtx(cmd.Context(), call.Tool, command, call.Agent, path, call.RunID, decision.Message, 5*time.Minute)
472564
return outputHookResult(cmd, format, result, false, decision.Message, cmdStr)
473565
}
566+
// Serve not running — fall back to native ask prompt.
567+
// This gives a better UX than blocking forever on a dashboard that isn't there.
568+
logger.Debug("hook: serve not running, falling back to native ask for require_approval")
474569
return outputHookResult(cmd, format, hookAsk, false, decision.Message, cmdStr)
475570
default:
476571
return outputHookResult(cmd, format, hookAllow, isPostToolUse, decision.Message, cmdStr)
@@ -509,6 +604,8 @@ func parseClaudeCodeInput(reader interface{ Read([]byte) (int, error) }, logger
509604
Agent: "claude-code",
510605
RunID: deriveRunID(input.SessionID),
511606
HookEventName: input.HookEventName,
607+
SessionID: input.SessionID,
608+
ToolUseID: input.ToolUseID,
512609
}
513610

514611
// Extract response text from PostToolUse tool_response.
@@ -608,7 +705,9 @@ func mapClaudeCodeTool(toolName string) string {
608705
// displays it distinctly from exec/read/write.
609706
return "agent"
610707
default:
611-
slog.Warn("hook: unmapped Claude Code tool name, defaulting to unknown", "tool_name", toolName)
708+
// NOTE: Don't log here - this function is called before the logger is available,
709+
// and any stderr output causes Claude Code to report "hook error".
710+
// Unknown tools are handled gracefully by returning "unknown".
612711
return "unknown"
613712
}
614713
}
@@ -631,7 +730,7 @@ func mapClineTool(toolName string) string {
631730
case "ask_followup_question", "attempt_completion", "new_task", "fetch_instructions", "plan_mode_respond":
632731
return "interact"
633732
default:
634-
slog.Warn("hook: unmapped Cline tool name, defaulting to unknown", "tool_name", toolName)
733+
// NOTE: Don't log here - any stderr output causes the agent to report "hook error".
635734
return "unknown"
636735
}
637736
}
@@ -655,9 +754,9 @@ func outputHookResult(cmd *cobra.Command, format string, decision hookDecisionTy
655754
if decision == hookDeny || decision == hookBlock {
656755
fmt.Fprint(os.Stderr, formatDenyMessage(command, reason, suggestions))
657756
}
658-
if decision == hookAsk {
659-
fmt.Fprint(os.Stderr, formatApprovalRequiredMessage(command, reason))
660-
}
757+
// NOTE: Do NOT print to stderr for hookAsk — Claude Code interprets any
758+
// stderr output as a hook error. The native prompt shows the reason via
759+
// PermissionDecisionReason in the JSON response.
661760
switch format {
662761
case "cline":
663762
// Cline has no "ask" — cancel on deny, block, and require_approval.

0 commit comments

Comments
 (0)