Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
64b7621
feat: persist pending approvals to disk — survive rampart serve restarts
Mar 25, 2026
749dcf5
fix: v1.0 prep — doctor ask check, smart allow-always globs, name-bas…
Mar 25, 2026
35caf51
fix: update dist patches for OpenClaw 2026.3.x bundle changes
Mar 27, 2026
a01bc62
fix: add read/write tool coverage to env policy, add --since to audit…
Mar 29, 2026
22fcac0
feat: POST /v1/rules/learn — always-allow writeback API for OpenClaw …
Mar 30, 2026
871c458
feat: v1.0 plugin integration — learn endpoint, setup --plugin, openc…
Mar 30, 2026
2fc6b07
feat: auto-detect OpenClaw version
Mar 30, 2026
6a2fa7f
feat: polish log output, install.sh UX, doctor summary
Mar 30, 2026
b8cf95e
fix: suppress web_fetch/browser/message/exec patch warnings when plug…
Mar 30, 2026
b23d174
docs: v0.9.10 changelog
Mar 30, 2026
1739434
fix: set USERPROFILE in learn handler tests for Windows compatibility
Mar 30, 2026
9120f55
fix: update e2e.yaml require_approval → ask, add toolList YAML unmars…
Mar 30, 2026
77297ee
fix: harden BuildAllowPattern — no trailing wildcard for high-risk pr…
Mar 30, 2026
aca6dff
fix: setup panic + mutex writes + doc corrections
Mar 30, 2026
352aaba
chore: remove accidental .gocache and TASK.md from commit
Mar 30, 2026
9bb4703
fix: harden BuildAllowPattern — no trailing wildcard for high-risk pr…
Mar 30, 2026
288032a
fix: setup panic + mutex writes + doc corrections
Mar 30, 2026
b39f2b0
fix: use forward-slash path matching in isSensitivePathToken (Windows…
Mar 30, 2026
f5966f6
fix: path traversal bypass + sudo wrapper bypass in glob safety checks
Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.9.10] - 2026-03-30

### Added (OpenClaw Native Plugin — Primary Integration)

- **Native OpenClaw plugin** (`rampart-openclaw-plugin`) — intercepts all tool calls via OpenClaw's `before_tool_call` hook API. Replaces brittle dist file patching. Works with OpenClaw >= 2026.3.28. Install with `rampart setup openclaw` (auto-detected).
- **`rampart setup openclaw` auto-detects OpenClaw version** — automatically uses the plugin path on OpenClaw >= 2026.3.28, falls back to legacy dist patches on older versions. No flags required.
- **`rampart setup openclaw` auto-starts serve** — if `rampart serve` isn't running when setup is invoked, it is installed and started as a systemd service automatically.
- **`POST /v1/rules/learn`** — new API endpoint for "Always Allow" writeback from the plugin. Accepts `{tool, args, decision, source}`, computes a smart glob pattern, and appends a persistent rule to `user-overrides.yaml`.
- **`policies/openclaw.yaml` profile** — 13 focused policies covering the full OpenClaw tool surface (exec, read, write, edit, web_fetch, web_search, browser, message, canvas).
- **`rampart setup openclaw --migrate`** — migrates from legacy dist-patch/bridge integration to the native plugin in one command.
- **`rampart doctor` plugin check** — shows `✓ OpenClaw plugin: installed (before_tool_call hook active)` when the plugin is present.
- **`rampart doctor` summary block** — when all checks pass, prints "🛡️ Rampart is protecting your AI agents" with live stats and next-step hints.
- **Approval store persistence** — pending approvals survive `rampart serve` restarts.
- **`rampart audit verify --since <date>`** — skip audit chain verification for files before a given date.

### Fixed

- **EOF noise removed** — hook parse failures from stdin EOF no longer appear in `rampart log --deny`.
- **OpenClaw 2026.3.x dist patch compatibility** — updated patterns for new bundle format.
- **Always Allow writeback** — clicking "Always Allow" in Discord now correctly writes a persistent rule.
- **Smart Always Allow globs** — writes `sudo apt-get install *` instead of exact strings.
- **`.env` file protection for read/write tools** — `watch-env-access` now covers `read` and `write`.
- **`rampart doctor` false positives** — web_fetch/browser/message/exec warnings suppressed when plugin is installed.
- **`rampart doctor` checks `tools.exec.ask`** — checks both top-level and nested config path.
- **Bridge audit trail** — bridge-evaluated commands now appear in JSONL with full params.
- **MCP proxy configurable agent identity** — `--agent-id` and `--session-id` flags.
- **Name-based rule deletion** — `DELETE /v1/rules/auto-allowed/{name}`.

### Changed

- **`install.sh` UX** — detects OpenClaw/Claude Code and prints setup command. `--auto-setup` flag available.
- **`rampart log` format** — cleaner icons, tool:decision token, policy name on right.



## [0.9.9] - 2026-03-24

### Removed (Breaking)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ rampart approve abc123 # Let it through
rampart deny abc123 # Block it
```

Pending approvals expire after 1 hour by default (`--approval-timeout` to change).
Pending approvals expire after 2 minutes by default (`--approval-timeout` to change).

---

Expand Down
36 changes: 36 additions & 0 deletions cmd/rampart/cli/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,18 @@ func followAuditFile(cmd *cobra.Command, auditDir, startFile string, noColor boo

func newAuditVerifyCmd() *cobra.Command {
var auditDir string
var since string

cmd := &cobra.Command{
Use: "verify",
Short: "Verify audit hash-chain integrity",
Long: `Verify the hash-chain integrity of audit log files.

Use --since to skip files before a given date (useful when older files have
a known break in the chain from a previous dev session).

Example:
rampart audit verify --since 2026-03-20`,
RunE: func(cmd *cobra.Command, _ []string) error {
files, err := listAuditFiles(auditDir)
if err != nil {
Expand All @@ -144,6 +152,33 @@ func newAuditVerifyCmd() *cobra.Command {
return fmt.Errorf("audit: no .jsonl files found in %s", auditDir)
}

// Filter files by --since date if provided
if since != "" {
sinceDate, parseErr := time.Parse("2006-01-02", since)
if parseErr != nil {
return fmt.Errorf("audit: invalid --since date %q: expected YYYY-MM-DD format", since)
}
filtered := files[:0]
for _, f := range files {
base := filepath.Base(f)
// Filename format: YYYY-MM-DD.jsonl
datePart := strings.TrimSuffix(base, ".jsonl")
fileDate, dateErr := time.Parse("2006-01-02", datePart)
if dateErr != nil {
// Can't parse date from filename — include it to be safe
filtered = append(filtered, f)
continue
}
if !fileDate.Before(sinceDate) {
filtered = append(filtered, f)
}
}
files = filtered
if len(files) == 0 {
return fmt.Errorf("audit: no .jsonl files found in %s at or after %s", auditDir, since)
}
}

count, hashesByID, err := verifyAuditChain(files)
if err != nil {
return err
Expand All @@ -161,6 +196,7 @@ func newAuditVerifyCmd() *cobra.Command {
}

cmd.Flags().StringVar(&auditDir, "audit-dir", "~/.rampart/audit", "Directory containing audit JSONL files")
cmd.Flags().StringVar(&since, "since", "", "Only verify files from this date forward (YYYY-MM-DD), skipping older files")
return cmd
}

Expand Down
151 changes: 145 additions & 6 deletions cmd/rampart/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,17 @@ func runDoctor(w io.Writer, jsonOut bool) error {
// 15. Project policy (informational only — not a failure)
doctorProjectPolicy(w, emit, collect)

// 16. Proactive policy suggestions (informational only)
// 16. OpenClaw ask mode check
if n := doctorOpenClawAskMode(emit); n > 0 {
warnings += n
}

// 17. OpenClaw plugin health
if n := doctorOpenClawPlugin(emit); n > 0 {
warnings += n
}

// 18. Proactive policy suggestions (informational only)
if detectResult, detectErr := detect.Environment(); detectErr == nil {
client := newPolicyRegistryClient()
if manifest, fetchErr := client.loadManifest(context.Background(), false); fetchErr == nil {
Expand Down Expand Up @@ -270,7 +280,7 @@ func runDoctor(w io.Writer, jsonOut bool) error {

fmt.Fprintln(w)
if issues == 0 && warnings == 0 {
fmt.Fprintln(w, "No issues found.")
printDoctorSummary(w, useColor)
} else if issues == 0 && warnings > 0 {
fmt.Fprintf(w, "%d warning(s) — not blocking but worth reviewing.\n", warnings)
} else {
Expand All @@ -292,6 +302,57 @@ func runDoctor(w io.Writer, jsonOut bool) error {
return nil
}

// printDoctorSummary prints an encouraging all-clear summary with next steps.
func printDoctorSummary(w io.Writer, useColor bool) {
green := ""
bold := ""
dim := ""
reset := ""
if useColor {
green = colorGreen
bold = "\033[1m"
dim = colorDim
reset = colorReset
}

fmt.Fprintf(w, "%s%s🛡️ Rampart is protecting your AI agents%s\n\n", bold, green, reset)

// Gather some live stats for the summary lines.
protected := detectProtectedAgents()
allow, deny, _, _ := todayEvents()
serverRunning := isServeRunningLocal()

// Server status line
if serverRunning {
fmt.Fprintf(w, " %s✓%s rampart serve running (:9090)\n", green, reset)
}

// Protected agents
if len(protected) > 0 {
for _, p := range protected {
fmt.Fprintf(w, " %s✓%s %s\n", green, reset, p)
}
}

// Policies
_, defaultAction := detectMode()
if defaultAction != "" {
fmt.Fprintf(w, " %s✓%s Policies loaded (default: %s)\n", green, reset, defaultAction)
}

// Audit events
total := allow + deny
if total > 0 {
fmt.Fprintf(w, " %s✓%s Audit trail: %d events logged today\n", green, reset, total)
}

// Next steps
fmt.Fprintln(w)
fmt.Fprintf(w, " %s→ rampart watch%s — see policy decisions in real time\n", dim, reset)
fmt.Fprintf(w, " %s→ rampart log%s — view recent allow/deny history\n", dim, reset)
fmt.Fprintf(w, " %s→ rampart status%s — dashboard summary\n", dim, reset)
}

type emitFn func(name, status, msg string)

func doctorToken(emit emitFn) (issues int, token string) {
Expand Down Expand Up @@ -823,13 +884,18 @@ func doctorPreload(emit emitFn) (warnings int) {
// are patched with Rampart policy checks. If the tools directory exists but files
// aren't patched, warns the user — this happens after npm upgrades.
func doctorFileToolPatches(emit emitFn) (warnings int) {
// If the native plugin is installed, it intercepts all tool calls via before_tool_call hook.
// Dist patches for web_fetch/browser/message/exec are redundant — only dist patches for
// read/write/edit (file content visibility) still add value.
pluginInstalled := isOpenClawPluginInstalled()

// Check if OpenClaw uses bundled dist files (#204).
if openclawUsesBundledDist() {
distPatched := openclawDistPatched()
webFetchPatched := openclawWebFetchPatched()
browserPatched := openclawBrowserPatched()
messagePatched := openclawMessagePatched()
execPatched := openclawExecPatched()
webFetchPatched := openclawWebFetchPatched() || pluginInstalled
browserPatched := openclawBrowserPatched() || pluginInstalled
messagePatched := openclawMessagePatched() || pluginInstalled
execPatched := openclawExecPatched() || pluginInstalled

if distPatched && webFetchPatched && browserPatched && messagePatched && execPatched {
emit("Tool patches", "ok", "All OpenClaw tools patched (read/write/edit + web_fetch + browser + message + exec)")
Expand Down Expand Up @@ -1171,6 +1237,79 @@ func doctorProjectPolicy(w io.Writer, emit emitFn, collect bool) {
}
}

// doctorOpenClawPlugin checks whether the Rampart native plugin is installed
// in ~/.openclaw/extensions/rampart/. This is the preferred integration method
// for OpenClaw >= 2026.3.28 (uses the before_tool_call hook instead of dist patches).
//
// Emits:
// - ok if plugin directory exists
// - warn (with hint) if OpenClaw is installed but plugin is missing
// - skipped silently if OpenClaw is not installed at all
func doctorOpenClawPlugin(emit emitFn) (warnings int) {
// Skip silently if OpenClaw is not installed.
if !isOpenClawInstalled() {
return 0
}

if isOpenClawPluginInstalled() {
emit("OpenClaw plugin", "ok", "installed (before_tool_call hook active)")
return 0
}

emit("OpenClaw plugin", "warn",
"not installed — native hook interception disabled"+hintSep+
"rampart setup openclaw --plugin")
return 1
}

// doctorOpenClawAskMode checks if ~/.openclaw/openclaw.json has ask set to
// "on-miss" or "always", which is required for exec approval events to reach
// Rampart's bridge. If the file doesn't exist, the check is skipped silently.
func doctorOpenClawAskMode(emit emitFn) (warnings int) {
home, err := os.UserHomeDir()
if err != nil {
return 0
}

configPath := filepath.Join(home, ".openclaw", "openclaw.json")
data, err := os.ReadFile(configPath)
if err != nil {
// File doesn't exist — not everyone uses OpenClaw, skip silently.
return 0
}

// ask can be set at top-level OR at tools.exec.ask
var cfg struct {
Ask string `json:"ask"`
Tools struct {
Exec struct {
Ask string `json:"ask"`
} `json:"exec"`
} `json:"tools"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
emit("OpenClaw ask mode", "warn", fmt.Sprintf("failed to parse %s: %v", configPath, err))
return 1
}

askVal := cfg.Ask
if askVal == "" {
askVal = cfg.Tools.Exec.Ask
}

switch askVal {
case "on-miss", "always":
emit("OpenClaw ask mode", "ok", fmt.Sprintf("%s (exec approvals will reach Rampart bridge)", askVal))
return 0
default:
emit("OpenClaw ask mode", "warn",
"not configured for exec interception"+hintSep+
"Add \"ask\": \"on-miss\" to ~/.openclaw/openclaw.json, then restart OpenClaw\n"+
" Without this, exec approval events are never sent to Rampart's bridge")
return 1
}
}

func relHome(path, home string) string {
if rel, err := filepath.Rel(home, path); err == nil {
return rel
Expand Down
12 changes: 12 additions & 0 deletions cmd/rampart/cli/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
Expand Down Expand Up @@ -352,6 +354,16 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
}
if err != nil {
logger.Warn("hook: failed to parse input", "format", format, "error", err)

// Clean EOF is normal — hook process exits when the agent
// restarts or stdin closes. Don't record an audit entry;
// it's not a real policy decision and just creates noise in
// `rampart log --deny`.
if errors.Is(err, io.EOF) || err.Error() == "EOF" {
logger.Debug("hook: clean EOF on stdin, skipping audit")
return outputHookResult(cmd, format, hookAllow, false, "parse failure: EOF", "")
}

// In enforce mode, fail closed — a parse failure must not silently
// allow the tool call. In monitor/audit modes, allow through so the
// agent is never blocked by a Rampart bug.
Expand Down
Loading
Loading