diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd53836..d061ac10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `** — 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) diff --git a/README.md b/README.md index 15bff27d..755c225c 100644 --- a/README.md +++ b/README.md @@ -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). --- diff --git a/cmd/rampart/cli/audit.go b/cmd/rampart/cli/audit.go index 06ce66c1..d0c32745 100644 --- a/cmd/rampart/cli/audit.go +++ b/cmd/rampart/cli/audit.go @@ -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 { @@ -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 @@ -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 } diff --git a/cmd/rampart/cli/doctor.go b/cmd/rampart/cli/doctor.go index 941f9e11..25fe15ce 100644 --- a/cmd/rampart/cli/doctor.go +++ b/cmd/rampart/cli/doctor.go @@ -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 { @@ -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 { @@ -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) { @@ -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)") @@ -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 diff --git a/cmd/rampart/cli/hook.go b/cmd/rampart/cli/hook.go index 37e1c6a9..d37490af 100644 --- a/cmd/rampart/cli/hook.go +++ b/cmd/rampart/cli/hook.go @@ -7,7 +7,9 @@ package cli import ( "context" "encoding/json" + "errors" "fmt" + "io" "log/slog" "net/http" "os" @@ -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. diff --git a/cmd/rampart/cli/log.go b/cmd/rampart/cli/log.go index 32020760..f87b5492 100644 --- a/cmd/rampart/cli/log.go +++ b/cmd/rampart/cli/log.go @@ -68,6 +68,10 @@ Examples: filtered := make([]audit.Event, 0, len(events)) for _, e := range events { if strings.EqualFold(e.Decision.Action, "deny") { + // Skip noise: hook process restarts show as "unknown" tool with EOF/parse failure messages + if isLogNoise(e) { + continue + } filtered = append(filtered, e) } } @@ -156,51 +160,75 @@ func writePrettyEvents(w io.Writer, events []audit.Event, disableColor bool) err return nil } +// isLogNoise returns true for events that are hook process restarts, not real policy +// decisions. These show as tool=="unknown" with EOF or parse failure messages. +func isLogNoise(e audit.Event) bool { + if !strings.EqualFold(strings.TrimSpace(e.Tool), "unknown") { + return false + } + msg := strings.ToLower(e.Decision.Message) + detail := strings.ToLower(extractPrimaryRequestValue(e.Request)) + combined := msg + " " + detail + return strings.Contains(combined, "eof") || + strings.Contains(combined, "parse failure") || + strings.Contains(combined, "parse error") || + strings.Contains(combined, "unexpected end") +} + // formatLogLine produces a single pretty-printed line for a log event. +// Format: HH:MM:SS ICON TOOL:DECISION COMMAND/PATH POLICY func formatLogLine(e audit.Event, disableColor bool) string { ts := e.Timestamp.Format("15:04:05") - tool := e.Tool + tool := strings.TrimSpace(e.Tool) + if tool == "" { + tool = "unknown" + } detail := extractPrimaryRequestValue(e.Request) - decision := strings.ToLower(e.Decision.Action) + decision := strings.ToLower(strings.TrimSpace(e.Decision.Action)) policy := "(no match)" if len(e.Decision.MatchedPolicies) > 0 { policy = e.Decision.MatchedPolicies[0] } - // Truncate detail - if len(detail) > 45 { - detail = detail[:42] + "..." + // Truncate detail to 48 chars + if len([]rune(detail)) > 48 { + runes := []rune(detail) + detail = string(runes[:45]) + "..." } - var icon, decLabel string + // toolDecision: "exec:deny", "read:allow", etc. + toolDecision := tool + ":" + decision + + var icon string switch decision { - case "allow": - icon = "✅" - decLabel = "allow" - case "deny": - icon = "🛡️" - decLabel = "deny" - case "log": - icon = "📝" - decLabel = "log" + case "allow", "approved", "always_allowed": + icon = "✓" + case "deny", "denied": + icon = "🛡" + case "ask", "require_approval": + icon = "⏸" + case "log", "watch": + icon = "👁" default: icon = "•" - decLabel = decision } - line := fmt.Sprintf("%s %s %-5s %-6s %-45s %s", ts, icon, decLabel, tool, detail, policy) + line := fmt.Sprintf("%s %s %-22s %-48s %s", ts, icon, toolDecision, detail, policy) if disableColor { return line } switch decision { - case "allow": - return "\033[32m" + line + "\033[0m" - case "deny": + case "allow", "approved", "always_allowed": + // Dim/faint for allow — not noise, just not alarming + return "\033[2m" + line + "\033[0m" + case "deny", "denied": return "\033[1;31m" + line + "\033[0m" - case "log": + case "ask", "require_approval": + return "\033[1;33m" + line + "\033[0m" + case "log", "watch": return "\033[33m" + line + "\033[0m" } return line diff --git a/cmd/rampart/cli/log_test.go b/cmd/rampart/cli/log_test.go index a6407fcd..9f2ac0f6 100644 --- a/cmd/rampart/cli/log_test.go +++ b/cmd/rampart/cli/log_test.go @@ -65,8 +65,8 @@ func TestFormatLogLine_NoColor(t *testing.T) { if !strings.Contains(line, "14:30:00") { t.Errorf("expected timestamp, got: %s", line) } - if !strings.Contains(line, "allow") { - t.Errorf("expected 'allow', got: %s", line) + if !strings.Contains(line, "exec:allow") { + t.Errorf("expected 'exec:allow', got: %s", line) } if !strings.Contains(line, "git status") { t.Errorf("expected command, got: %s", line) @@ -76,8 +76,8 @@ func TestFormatLogLine_NoColor(t *testing.T) { } line2 := formatLogLine(events[1], true) - if !strings.Contains(line2, "deny") { - t.Errorf("expected 'deny', got: %s", line2) + if !strings.Contains(line2, "exec:deny") { + t.Errorf("expected 'exec:deny', got: %s", line2) } if !strings.Contains(line2, "block-destructive") { t.Errorf("expected policy name, got: %s", line2) @@ -87,14 +87,51 @@ func TestFormatLogLine_NoColor(t *testing.T) { func TestFormatLogLine_WithColor(t *testing.T) { events := testEvents() + // allow entries should be dimmed (\033[2m) line := formatLogLine(events[0], false) - if !strings.Contains(line, "\033[32m") { - t.Errorf("expected green color for allow, got: %s", line) + if !strings.Contains(line, "\033[2m") { + t.Errorf("expected dim color for allow, got: %q", line) } + // deny entries should be bright red line2 := formatLogLine(events[1], false) if !strings.Contains(line2, "\033[1;31m") { - t.Errorf("expected red color for deny, got: %s", line2) + t.Errorf("expected red color for deny, got: %q", line2) + } +} + +func TestIsLogNoise(t *testing.T) { + eofEvent := audit.Event{ + Tool: "unknown", + Decision: audit.EventDecision{ + Action: "deny", + Message: "unexpected EOF", + }, + } + if !isLogNoise(eofEvent) { + t.Error("expected EOF unknown event to be noise") + } + + realEvent := audit.Event{ + Tool: "exec", + Request: map[string]any{"command": "cat ~/.ssh/id_rsa"}, + Decision: audit.EventDecision{ + Action: "deny", + MatchedPolicies: []string{"block-credential-commands"}, + }, + } + if isLogNoise(realEvent) { + t.Error("expected real deny event to NOT be noise") + } + + unknownRealEvent := audit.Event{ + Tool: "unknown", + Decision: audit.EventDecision{ + Action: "deny", + }, + } + if isLogNoise(unknownRealEvent) { + t.Error("expected unknown tool without EOF/parse message to NOT be filtered") } } diff --git a/cmd/rampart/cli/mcp.go b/cmd/rampart/cli/mcp.go index d1ac80f7..a62c1c3c 100644 --- a/cmd/rampart/cli/mcp.go +++ b/cmd/rampart/cli/mcp.go @@ -79,6 +79,8 @@ func newMCPProxyCmd(opts *rootOptions, deps *mcpDeps) *cobra.Command { var mode string var auditDir string var filterTools bool + var agentID string + var sessionID string resolvedDeps := defaultMCPDeps() if deps != nil { @@ -187,6 +189,8 @@ func newMCPProxyCmd(opts *rootOptions, deps *mcpDeps) *cobra.Command { mcp.WithFilterTools(filterTools), mcp.WithApprovalStore(approvalStore), mcp.WithLogger(logger), + mcp.WithAgentID(agentID), + mcp.WithSessionID(sessionID), ) proxyCtx, cancel := context.WithCancel(cmd.Context()) @@ -251,6 +255,8 @@ func newMCPProxyCmd(opts *rootOptions, deps *mcpDeps) *cobra.Command { cmd.Flags().StringVar(&mode, "mode", "enforce", "Mode: enforce | monitor") cmd.Flags().StringVar(&auditDir, "audit-dir", "", "Directory for audit logs (default: ~/.rampart/audit)") cmd.Flags().BoolVar(&filterTools, "filter-tools", false, "Filter denied tools from tools/list responses") + cmd.Flags().StringVar(&agentID, "agent-id", "", "Agent identity for policy evaluation and audit (default: mcp-client)") + cmd.Flags().StringVar(&sessionID, "session-id", "", "Session identity for policy evaluation and audit (default: mcp-proxy)") return cmd } diff --git a/cmd/rampart/cli/serve.go b/cmd/rampart/cli/serve.go index 661fc784..2d52f9df 100644 --- a/cmd/rampart/cli/serve.go +++ b/cmd/rampart/cli/serve.go @@ -337,6 +337,11 @@ func newServeCmd(opts *rootOptions, deps *serveDeps) *cobra.Command { if approvalTimeout > 0 { proxyOpts = append(proxyOpts, proxy.WithApprovalTimeout(approvalTimeout)) } + // Persist pending approvals so they survive a serve restart. + if rampartDir != "" { + persistFile := filepath.Join(rampartDir, "pending-approvals.jsonl") + proxyOpts = append(proxyOpts, proxy.WithApprovalPersistenceFile(persistFile)) + } // Resolve token: flag > env > persisted file > generate new. // Mirrors serve install behaviour so the token survives restarts. { diff --git a/cmd/rampart/cli/setup.go b/cmd/rampart/cli/setup.go index a19a91db..f4b355e7 100644 --- a/cmd/rampart/cli/setup.go +++ b/cmd/rampart/cli/setup.go @@ -343,6 +343,8 @@ func newSetupOpenClawCmd(opts *rootOptions) *cobra.Command { var patchToolsOnly bool var noPreload bool var shimOnly bool + var plugin bool + var migrate bool cmd := &cobra.Command{ Use: "openclaw", @@ -363,9 +365,26 @@ Use --shim-only to skip LD_PRELOAD and use the legacy shell shim approach. Use --no-preload to skip the LD_PRELOAD drop-in (still patches file tools). Use --remove to uninstall (preserves policies and audit logs).`, RunE: func(cmd *cobra.Command, _ []string) error { + if plugin { + return runSetupOpenClawPlugin(cmd.OutOrStdout(), cmd.ErrOrStderr()) + } + if migrate { + return runSetupOpenClawMigrate(cmd.OutOrStdout(), cmd.ErrOrStderr()) + } if remove { return removeOpenClaw(cmd) } + // Auto-detect: if OpenClaw >= 2026.3.28 is installed and --plugin/--migrate + // weren't explicitly requested, use the native plugin path automatically. + // Skip in test environments (RAMPART_TEST=1) to avoid running openclaw binary. + if !patchTools && !patchToolsOnly && !shimOnly && !noPreload && os.Getenv("RAMPART_TEST") != "1" { + if ver, err := detectOpenClawVersion(); err == nil { + if ok, _ := openclawVersionAtLeast(ver, openclawMinVersion); ok { + fmt.Fprintf(cmd.OutOrStdout(), "✓ OpenClaw %s detected — using native plugin integration\n", ver) + return runSetupOpenClawPlugin(cmd.OutOrStdout(), cmd.ErrOrStderr()) + } + } + } // --patch-tools-only: used by ExecStartPre to re-patch file tools // without writing drop-ins or starting services (avoids systemd deadlock). if patchToolsOnly { @@ -593,6 +612,8 @@ Use --remove to uninstall (preserves policies and audit logs).`, cmd.Flags().BoolVar(&noPreload, "no-preload", false, "Skip LD_PRELOAD but still auto-patch file tools via systemd drop-in") cmd.Flags().BoolVar(&shimOnly, "shim-only", false, "Use legacy shell shim only (no systemd drop-in, no sub-agent coverage)") cmd.Flags().IntVar(&port, "port", defaultServePort, "Port for Rampart policy server") + cmd.Flags().BoolVar(&plugin, "plugin", false, "Install the Rampart native OpenClaw plugin (requires OpenClaw >= "+openclawMinVersion+")") + cmd.Flags().BoolVar(&migrate, "migrate", false, "Migrate from legacy dist-patch/bridge integration to native plugin") return cmd } @@ -1523,8 +1544,10 @@ func patchWebFetchInDist(cmd *cobra.Command, distDir, url, tokenExpr string) boo } text := string(content) - // Skip files without web_fetch handler - if !strings.Contains(text, `const url = readStringParam(params, "url", { required: true`) { + // Skip files without web_fetch handler — check both old and new bundle patterns + hasWebFetch := strings.Contains(text, `const url = readStringParam(params, "url", { required: true`) || + strings.Contains(text, `const url = readStringParam$1(rawParams, "url", { required: true`) + if !hasWebFetch { continue } @@ -1535,8 +1558,11 @@ func patchWebFetchInDist(cmd *cobra.Command, distDir, url, tokenExpr string) boo continue } - // The anchor: the line after extracting the url param + // Try both old and new bundle patterns (OpenClaw 2026.3.x renamed readStringParam → readStringParam$1) webFetchOrig := `const url = readStringParam(params, "url", { required: true });` + if !strings.Contains(text, webFetchOrig) { + webFetchOrig = `const url = readStringParam$1(rawParams, "url", { required: true });` + } webFetchCheck := fmt.Sprintf(`const url = readStringParam(params, "url", { required: true }); /* RAMPART_DIST_CHECK_WEBFETCH */ try { const __wfu = typeof url === "string" ? url : ""; @@ -1686,8 +1712,13 @@ func patchMessageInDist(cmd *cobra.Command, distDir, url, tokenExpr string) bool continue } - messageOrig := `const result = await runMessageAction({` - messageCheck := fmt.Sprintf(`/* RAMPART_DIST_CHECK_MESSAGE */ try { + // Try both old and new dist bundle patterns for runMessageAction. + // OpenClaw 2026.3.x changed from `const result = await runMessageAction({` + // to `= async () => await runMessageAction({`. + messagePatterns := []struct{ orig, replacement string }{ + { + orig: `const result = await runMessageAction({`, + replacement: fmt.Sprintf(`/* RAMPART_DIST_CHECK_MESSAGE */ try { const __maa = typeof params.action === "string" ? params.action : "send"; const __mat = typeof params.target === "string" ? params.target : (typeof params.to === "string" ? params.to : (typeof params.channelId === "string" ? params.channelId : "")); const __mac = typeof params.message === "string" ? params.message : (typeof params.text === "string" ? params.text : (typeof params.content === "string" ? params.content : "")); @@ -1702,10 +1733,38 @@ func patchMessageInDist(cmd *cobra.Command, distDir, url, tokenExpr string) bool return { content: [{ type: "text", text: "rampart: " + (__mad.message || "policy denied") }] }; } } catch (__mae) { /* fail-open */ } - const result = await runMessageAction({`, url, tokenExpr) + const result = await runMessageAction({`, url, tokenExpr), + }, + { + orig: `= async () => await runMessageAction({`, + replacement: fmt.Sprintf(`= async () => { /* RAMPART_DIST_CHECK_MESSAGE */ try { + const __maa = typeof params.action === "string" ? params.action : "send"; + const __mat = typeof params.target === "string" ? params.target : (typeof params.to === "string" ? params.to : (typeof params.channelId === "string" ? params.channelId : "")); + const __mac = typeof params.message === "string" ? params.message : (typeof params.text === "string" ? params.text : (typeof params.content === "string" ? params.content : "")); + const __mar = await fetch((process.env.RAMPART_URL || "%s") + "/v1/tool/message", { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": "Bearer " + (%s) }, + body: JSON.stringify({ agent: "openclaw", session: "main", params: { action: __maa, target: __mat, message: __mac } }), + signal: AbortSignal.timeout(3000) + }); + if (__mar.status === 403) { + const __mad = await __mar.json().catch(() => ({})); + return { content: [{ type: "text", text: "rampart: " + (__mad.message || "policy denied") }] }; + } + } catch (__mae) { /* fail-open */ } return await runMessageAction({`, url, tokenExpr), + }, + } - modified := strings.Replace(text, messageOrig, messageCheck, 1) - if modified == text { + modified := text + injected := false + for _, mp := range messagePatterns { + if strings.Contains(modified, mp.orig) { + modified = strings.Replace(modified, mp.orig, mp.replacement, 1) + injected = true + break + } + } + if !injected { fmt.Fprintf(cmd.ErrOrStderr(), " ⚠ %s: message injection point not found (skipping)\n", filepath.Base(file)) continue } diff --git a/cmd/rampart/cli/setup_openclaw_plugin.go b/cmd/rampart/cli/setup_openclaw_plugin.go new file mode 100644 index 00000000..3350a5b6 --- /dev/null +++ b/cmd/rampart/cli/setup_openclaw_plugin.go @@ -0,0 +1,511 @@ +// Copyright 2026 The Rampart Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + osexec "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/peg/rampart/policies" +) + +// openclawPluginDir is the well-known directory where the Rampart OpenClaw +// plugin is installed by `openclaw plugins install`. +const openclawPluginDir = "extensions/rampart" + +// openclawMinVersion is the minimum OpenClaw version required for the +// before_tool_call hook used by the Rampart plugin. +const openclawMinVersion = "2026.3.28" + +// TODO: bundle the plugin inside the rampart binary and extract to a temp dir. +// For now, point at the development checkout path. +const openclawPluginDevPath = "/home/clap/.openclaw/workspace/rampart-openclaw-plugin" + +// runSetupOpenClawPlugin installs the Rampart native plugin into OpenClaw. +// +// Steps: +// 1. Locate the openclaw binary. +// 2. Verify the OpenClaw version is >= openclawMinVersion (requires before_tool_call hook). +// 3. Run: openclaw plugins install +// 4. Set tools.exec.ask to "off" in ~/.openclaw/openclaw.json +// (Rampart handles all decisions now — no need for OpenClaw's own ask prompt). +// 5. Copy the openclaw.yaml policy profile to ~/.rampart/policies/openclaw.yaml. +// 6. Run rampart doctor for a health summary. +// 7. Print success and next steps. +func runSetupOpenClawPlugin(w io.Writer, errW io.Writer) error { + // 0. Ensure rampart serve is running (start as systemd service if needed). + if err := ensureServeRunning(w, errW); err != nil { + fmt.Fprintf(errW, "⚠ Could not start rampart serve: %v\n", err) + fmt.Fprintln(errW, " Start manually: rampart serve --background") + // Non-fatal — continue setup, serve can be started later. + } + + // 1. Locate openclaw. + openclawBin, err := findOpenClawBinary() + if err != nil { + return fmt.Errorf("openclaw not found — is it installed?\n Install: npm install -g openclaw\n Error: %w", err) + } + fmt.Fprintf(w, "✓ Found OpenClaw: %s\n", openclawBin) + + // 2. Check version. + version, err := getOpenClawVersion(openclawBin) + if err != nil { + fmt.Fprintf(errW, "⚠ Could not determine OpenClaw version: %v\n", err) + fmt.Fprintln(errW, " Continuing anyway — plugin install may fail if version is too old.") + } else { + ok, cmpErr := openclawVersionAtLeast(version, openclawMinVersion) + if cmpErr != nil { + fmt.Fprintf(errW, "⚠ Could not parse OpenClaw version %q: %v\n", version, cmpErr) + } else if !ok { + return fmt.Errorf("OpenClaw version %s is too old — need >= %s for before_tool_call hook\n Upgrade: npm install -g openclaw@latest", version, openclawMinVersion) + } + fmt.Fprintf(w, "✓ OpenClaw version: %s (>= %s required)\n", version, openclawMinVersion) + } + + // 3. Install the plugin. + pluginPath := openclawPluginDevPath + if _, err := os.Stat(pluginPath); os.IsNotExist(err) { + return fmt.Errorf("Rampart OpenClaw plugin not found at %s\n Build it first: cd %s && npm install && npm run build", pluginPath, pluginPath) + } + fmt.Fprintf(w, "Installing plugin from: %s\n", pluginPath) + + installCmd := osexec.Command(openclawBin, "plugins", "install", pluginPath) + installCmd.Stdout = w + installCmd.Stderr = errW + if err := installCmd.Run(); err != nil { + return fmt.Errorf("openclaw plugins install failed: %w\n Try running manually: openclaw plugins install %s", err, pluginPath) + } + fmt.Fprintln(w, "✓ Rampart plugin installed into OpenClaw") + + // 4. Set tools.exec.ask to "off" in openclaw.json. + if err := setOpenClawExecAsk("off"); err != nil { + fmt.Fprintf(errW, "⚠ Could not update tools.exec.ask in openclaw.json: %v\n", err) + fmt.Fprintln(errW, " Add manually: set tools.exec.ask to \"off\" in ~/.openclaw/openclaw.json") + } else { + fmt.Fprintln(w, "✓ Set tools.exec.ask = \"off\" (Rampart now handles all decisions)") + } + + // 5. Copy openclaw.yaml policy profile. + if err := installOpenClawPolicy(w, errW); err != nil { + fmt.Fprintf(errW, "⚠ Could not install openclaw.yaml policy: %v\n", err) + } + + // 6. Run rampart doctor. + fmt.Fprintln(w, "") + fmt.Fprintln(w, "Running 'rampart doctor'...") + doctorCmd := osexec.Command(os.Args[0], "doctor") + doctorCmd.Stdout = w + doctorCmd.Stderr = errW + _ = doctorCmd.Run() // non-fatal — doctor output is informational + + // 7. Success message. + fmt.Fprintln(w, "") + fmt.Fprintln(w, "✅ Rampart is protecting your OpenClaw agent") + fmt.Fprintln(w, "") + fmt.Fprintln(w, " Protected tools: exec, read, write, edit, web_fetch, browser, message") + serveStatus := "http://localhost:9090" + if isSetupServeReachable() { + serveStatus += " (running)" + } else { + serveStatus += " (not running — start with: rampart serve --background)" + } + fmt.Fprintf(w, " Policy engine: %s\n", serveStatus) + fmt.Fprintln(w, " Audit log: ~/.rampart/audit/") + fmt.Fprintln(w, "") + fmt.Fprintln(w, " → Restart the gateway: systemctl --user restart openclaw-gateway") + fmt.Fprintln(w, " → Run `rampart watch` to see policy decisions in real time") + fmt.Fprintln(w, " → Run `rampart doctor` to verify your setup") + + return nil +} + +// runSetupOpenClawMigrate migrates from the legacy dist-patch/bridge approach +// to the native plugin-based integration. +// +// Steps: +// 1. Remove old dist patches (restore .rampart-backup files if they exist). +// 2. Remove the old bridge config from openclaw.json. +// 3. Remove ask: on-miss if it exists. +// 4. Install the plugin (calls runSetupOpenClawPlugin). +// 5. Print migration summary. +func runSetupOpenClawMigrate(w io.Writer, errW io.Writer) error { + fmt.Fprintln(w, "Migrating from legacy OpenClaw integration to native plugin...") + fmt.Fprintln(w, "") + + var removed []string + + // 1. Restore dist patch backups. + for _, distDir := range openclawDistCandidates() { + allJS, _ := filepath.Glob(filepath.Join(distDir, "*.js")) + for _, file := range allJS { + backup := file + ".rampart-backup" + if data, err := os.ReadFile(backup); err == nil { + if err := os.WriteFile(file, data, 0o644); err == nil { + _ = os.Remove(backup) + removed = append(removed, filepath.Base(file)+" (dist patch restored)") + } else { + fmt.Fprintf(errW, "⚠ Could not restore %s: %v\n", file, err) + } + } + } + } + if len(removed) > 0 { + fmt.Fprintf(w, "✓ Restored %d dist file(s) from backup\n", len(removed)) + } else { + fmt.Fprintln(w, " No dist patch backups found (already clean or not applied)") + } + + // 2 & 3. Update openclaw.json: remove bridge config, remove ask: on-miss. + if err := cleanOpenClawConfig(w, errW); err != nil { + fmt.Fprintf(errW, "⚠ Could not clean openclaw.json: %v\n", err) + } + + // 4. Install the plugin. + fmt.Fprintln(w, "") + fmt.Fprintln(w, "Installing Rampart native plugin...") + if err := runSetupOpenClawPlugin(w, errW); err != nil { + return fmt.Errorf("plugin install failed during migration: %w", err) + } + + // 5. Migration summary. + fmt.Fprintln(w, "") + fmt.Fprintln(w, "Migration complete!") + if len(removed) > 0 { + for _, r := range removed { + fmt.Fprintf(w, " Cleaned: %s\n", r) + } + } + fmt.Fprintln(w, "") + fmt.Fprintln(w, "The legacy dist patches and bridge are no longer needed.") + fmt.Fprintln(w, "The native before_tool_call hook provides full coverage.") + + return nil +} + +// findOpenClawBinary returns the path to the openclaw binary. +func findOpenClawBinary() (string, error) { + // Try PATH first. + if p, err := osexec.LookPath("openclaw"); err == nil { + return p, nil + } + // Try common install paths. + home, _ := os.UserHomeDir() + candidates := []string{ + filepath.Join(home, ".local", "bin", "openclaw"), + "/usr/local/bin/openclaw", + "/usr/bin/openclaw", + filepath.Join(home, ".npm-global", "bin", "openclaw"), + "/opt/homebrew/bin/openclaw", + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("openclaw binary not found in PATH or common locations") +} + +// getOpenClawVersion runs `openclaw --version` and returns the version string. +func getOpenClawVersion(openclawBin string) (string, error) { + out, err := osexec.Command(openclawBin, "--version").Output() + if err != nil { + // Try version subcommand. + out, err = osexec.Command(openclawBin, "version").Output() + if err != nil { + return "", fmt.Errorf("run %s --version: %w", openclawBin, err) + } + } + version := strings.TrimSpace(string(out)) + // Strip common prefixes like "openclaw v2026.3.28" or "v2026.3.28". + for _, prefix := range []string{"openclaw ", "OpenClaw ", "v", "V"} { + version = strings.TrimPrefix(version, prefix) + } + // Take the first line/word only. + fields := strings.Fields(version) + if len(fields) > 0 { + version = fields[0] + } + return version, nil +} + +// openclawVersionAtLeast returns true if gotVersion >= minVersion. +// Version format: YYYY.M.D (e.g. 2026.3.28). +func openclawVersionAtLeast(gotVersion, minVersion string) (bool, error) { + got := parseCalVer(gotVersion) + min := parseCalVer(minVersion) + if got == nil || min == nil { + return false, fmt.Errorf("could not parse versions: got=%q min=%q", gotVersion, minVersion) + } + for i := range min { + if i >= len(got) { + return false, nil + } + if got[i] > min[i] { + return true, nil + } + if got[i] < min[i] { + return false, nil + } + } + return true, nil +} + +// parseCalVer splits a CalVer string like "2026.3.28" into []int{2026, 3, 28}. +func parseCalVer(v string) []int { + parts := strings.Split(v, ".") + result := make([]int, 0, len(parts)) + for _, p := range parts { + var n int + if _, err := fmt.Sscanf(p, "%d", &n); err != nil { + return nil + } + result = append(result, n) + } + if len(result) == 0 { + return nil + } + return result +} + +// setOpenClawExecAsk sets tools.exec.ask in ~/.openclaw/openclaw.json. +func setOpenClawExecAsk(value string) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("resolve home: %w", err) + } + + configPath := filepath.Join(home, ".openclaw", "openclaw.json") + + // Load existing config or start fresh. + var cfg map[string]any + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("parse %s: %w", configPath, err) + } + } else if os.IsNotExist(err) { + cfg = make(map[string]any) + // Ensure parent directory exists. + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + return fmt.Errorf("create .openclaw dir: %w", err) + } + } else { + return fmt.Errorf("read %s: %w", configPath, err) + } + + // Navigate to tools.exec and set ask. + tools, ok := cfg["tools"].(map[string]any) + if !ok { + tools = make(map[string]any) + cfg["tools"] = tools + } + execCfg, ok := tools["exec"].(map[string]any) + if !ok { + execCfg = make(map[string]any) + tools["exec"] = execCfg + } + execCfg["ask"] = value + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + data = append(data, '\n') + + if err := os.WriteFile(configPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", configPath, err) + } + return nil +} + +// installOpenClawPolicy copies the embedded openclaw.yaml policy to ~/.rampart/policies/. +func installOpenClawPolicy(w io.Writer, errW io.Writer) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("resolve home: %w", err) + } + + policyDir := filepath.Join(home, ".rampart", "policies") + if err := os.MkdirAll(policyDir, 0o700); err != nil { + return fmt.Errorf("create policy dir: %w", err) + } + + policyData, err := policies.Profile("openclaw") + if err != nil { + return fmt.Errorf("load embedded openclaw.yaml: %w", err) + } + + destPath := filepath.Join(policyDir, "openclaw.yaml") + if err := os.WriteFile(destPath, policyData, 0o600); err != nil { + return fmt.Errorf("write %s: %w", destPath, err) + } + + fmt.Fprintf(w, "✓ OpenClaw policy profile installed at %s\n", destPath) + return nil +} + +// cleanOpenClawConfig removes legacy bridge config and ask: on-miss from openclaw.json. +func cleanOpenClawConfig(w io.Writer, errW io.Writer) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("resolve home: %w", err) + } + + configPath := filepath.Join(home, ".openclaw", "openclaw.json") + data, err := os.ReadFile(configPath) + if os.IsNotExist(err) { + fmt.Fprintln(w, " No openclaw.json found — nothing to clean") + return nil + } + if err != nil { + return fmt.Errorf("read %s: %w", configPath, err) + } + + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("parse %s: %w", configPath, err) + } + + changed := false + + // Remove top-level ask: on-miss (legacy; Rampart plugin handles it now). + if askVal, ok := cfg["ask"].(string); ok && (askVal == "on-miss" || askVal == "always") { + delete(cfg, "ask") + changed = true + fmt.Fprintf(w, " Removed top-level ask: %s\n", askVal) + } + + // Remove tools.exec.ask: on-miss if present (plugin hook replaces this). + if tools, ok := cfg["tools"].(map[string]any); ok { + if execCfg, ok := tools["exec"].(map[string]any); ok { + if askVal, ok := execCfg["ask"].(string); ok && (askVal == "on-miss" || askVal == "always") { + delete(execCfg, "ask") + changed = true + fmt.Fprintf(w, " Removed tools.exec.ask: %s\n", askVal) + } + } + } + + // Remove legacy rampart bridge config keys if present. + bridgeKeys := []string{"rampart", "rampartBridge", "rampart_bridge", "rampartUrl", "rampart_url"} + for _, k := range bridgeKeys { + if _, ok := cfg[k]; ok { + delete(cfg, k) + changed = true + fmt.Fprintf(w, " Removed legacy bridge config key: %s\n", k) + } + } + + if !changed { + fmt.Fprintln(w, " openclaw.json is already clean") + return nil + } + + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + out = append(out, '\n') + + if err := os.WriteFile(configPath, out, 0o600); err != nil { + return fmt.Errorf("write %s: %w", configPath, err) + } + + return nil +} + +// isOpenClawInstalled returns true if the openclaw binary can be found. +func isOpenClawInstalled() bool { + _, err := findOpenClawBinary() + return err == nil +} + +// isOpenClawPluginInstalled returns true if the Rampart plugin directory +// exists under ~/.openclaw/extensions/rampart/. +func isOpenClawPluginInstalled() bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + pluginDir := filepath.Join(home, ".openclaw", openclawPluginDir) + info, err := os.Stat(pluginDir) + return err == nil && info.IsDir() +} + +// detectOpenClawVersion finds the OpenClaw binary and returns its version string. +// Returns an error if OpenClaw is not installed or version cannot be determined. +func detectOpenClawVersion() (string, error) { + bin, err := findOpenClawBinary() + if err != nil { + return "", err + } + return getOpenClawVersion(bin) +} + +// ensureServeRunning checks whether rampart serve is reachable, and if not, +// installs and starts it as a systemd/launchd service via `rampart serve install`. +func ensureServeRunning(w io.Writer, errW io.Writer) error { + if isSetupServeReachable() { + fmt.Fprintln(w, "✓ Rampart serve is running") + return nil + } + + fmt.Fprintln(w, "Starting rampart serve...") + rampartBin, err := os.Executable() + if err != nil { + return fmt.Errorf("find rampart binary: %w", err) + } + + installCmd := osexec.Command(rampartBin, "serve", "install") + installCmd.Stdout = w + installCmd.Stderr = errW + if err := installCmd.Run(); err != nil { + return fmt.Errorf("rampart serve install failed: %w", err) + } + + // Wait up to 3 seconds for serve to come up. + for i := 0; i < 6; i++ { + time.Sleep(500 * time.Millisecond) + if isSetupServeReachable() { + fmt.Fprintln(w, "✓ Rampart serve started (systemd service)") + return nil + } + } + + return fmt.Errorf("rampart serve installed but not reachable after 3s") +} + +// isSetupServeReachable does a quick healthz check against the default serve port. +func isSetupServeReachable() bool { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:9090/healthz", nil) + if err != nil { + return false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode >= 200 && resp.StatusCode < 400 +} diff --git a/cmd/rampart/cli/setup_preload_test.go b/cmd/rampart/cli/setup_preload_test.go index 065d1330..ee1b6be5 100644 --- a/cmd/rampart/cli/setup_preload_test.go +++ b/cmd/rampart/cli/setup_preload_test.go @@ -11,6 +11,12 @@ import ( "testing" ) +func TestMain(m *testing.M) { + // Prevent setup commands from auto-detecting and calling the real openclaw binary. + os.Setenv("RAMPART_TEST", "1") + os.Exit(m.Run()) +} + func TestGenerateShimContent(t *testing.T) { shim := generateShimContent("/bin/bash", 19090, "rampart_test123") diff --git a/docs-site/features/mcp-proxy.md b/docs-site/features/mcp-proxy.md index 8c75c767..0fe32088 100644 --- a/docs-site/features/mcp-proxy.md +++ b/docs-site/features/mcp-proxy.md @@ -201,7 +201,7 @@ policies: match: tool: ["mcp"] rules: - - action: log + - action: log # Renamed to action: watch in v0.9.x. message: "MCP tool call logged" ``` @@ -225,7 +225,7 @@ policies: match: tool: ["mcp__proxmox__vm_stop", "mcp__proxmox__vm_shutdown"] rules: - - action: log + - action: log # Renamed to action: watch in v0.9.x. message: "VM power operation logged" - name: block-disk-resize diff --git a/docs-site/getting-started/configuration.md b/docs-site/getting-started/configuration.md index fe374807..7cf9adb2 100644 --- a/docs-site/getting-started/configuration.md +++ b/docs-site/getting-started/configuration.md @@ -207,7 +207,7 @@ policies: match: tool: ["exec"] rules: - - action: log + - action: log # Renamed to action: watch in v0.9.x. when: command_matches: ["curl *"] ``` diff --git a/docs-site/guides/ci-headless.md b/docs-site/guides/ci-headless.md new file mode 100644 index 00000000..d5a688b6 --- /dev/null +++ b/docs-site/guides/ci-headless.md @@ -0,0 +1,167 @@ +--- +title: CI/Headless Agents +description: Configure Rampart for unattended agents with strict defaults and no interactive approvals. +--- + +# CI/Headless Agents + +When running AI agents in CI pipelines, automated workflows, or other headless environments, interactive approval prompts are impossible. Rampart provides the `ci` policy preset to convert all approval-required operations into hard denies. + +## Quick Start + +```bash +# Use the CI preset instead of standard +rampart init --profile ci +``` + +## What CI Mode Does + +The `ci` preset is a strict variant of the standard policy: + +| Standard Policy | CI Policy | +|-----------------|-----------| +| `action: ask` → native prompt | `action: deny` | +| `action: ask` (with `audit: true`) → dashboard | `action: deny` | +| Package installs → approval | Package installs → **blocked** | +| Cloud uploads → approval | Cloud uploads → **blocked** | +| Persistence changes → approval | Persistence changes → **blocked** | + +## Why Use It + +**Problem:** In CI, there's no human to click "Allow" on Claude Code's permission prompt. Without the CI preset: +- `action: ask` rules hang forever waiting for input +- `action: ask` (with `audit: true`) rules poll the dashboard indefinitely +- Your pipeline times out or runs forever + +**Solution:** The CI preset converts all interactive rules to denies. The agent completes (or fails fast) with no human intervention needed. + +## The `headless_only` Flag + +For fine-grained control, use `headless_only: true` in your ask rules: + +```yaml +policies: + - name: production-deploys + match: + tool: ["exec"] + rules: + - action: ask + ask: + audit: true + headless_only: true # ← blocks in CI, prompts interactively + when: + command_matches: + - "kubectl apply *" + message: "Production deployment requires approval" +``` + +**How it works:** +- **Interactive session** (Claude Code with user): Shows native approval prompt +- **Headless/CI** (no `rampart serve`, no TTY): Blocks with a deny + +This lets you write one policy that works both locally (with prompts) and in CI (with denies). + +### Detecting Headless Mode + +Rampart considers a session "headless" when: +1. `rampart serve` is not running, OR +2. The hook is invoked without a TTY (piped stdin) + +You can force headless mode with `RAMPART_HEADLESS=1`. + +## Example: GitHub Actions + +```yaml +# .github/workflows/ai-agent.yml +jobs: + agent: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rampart + run: curl -fsSL https://rampart.sh/install | bash + + - name: Configure CI policy + run: rampart init --profile ci + + - name: Run agent + run: | + rampart wrap -- python agent.py + env: + RAMPART_HEADLESS: "1" +``` + +## Customizing CI Behavior + +The built-in `ci` profile is intentionally strict. To customize: + +```bash +# Create a custom CI policy based on the preset +cp ~/.rampart/policies/ci.yaml ~/.rampart/policies/ci-custom.yaml +``` + +Then edit `ci-custom.yaml` to allow specific operations: + +```yaml +# Allow npm install in CI (after the built-in deny rule) +policies: + - name: ci-allow-npm + priority: 0 # Higher priority than default rules + match: + tool: ["exec"] + rules: + - action: allow + when: + command_matches: + - "npm ci" # Deterministic installs only + - "npm install --frozen-lockfile" +``` + +## Combining with Project Policies + +Project policies (`.rampart/policy.yaml` in your repo) are loaded on top of the global policy. In CI: + +1. Global CI policy (`ci.yaml`) provides the strict baseline +2. Project policy adds repo-specific overrides +3. `RAMPART_NO_PROJECT_POLICY=1` disables project policies if needed + +```yaml +# .rampart/policy.yaml in your repo +version: "1" +policies: + - name: project-allow-specific-deploy + match: + tool: ["exec"] + rules: + - action: allow + when: + command_matches: + - "kubectl apply -f k8s/staging/" # Allow staging only +``` + +## Audit in CI + +Even with denies, you want visibility. Run `rampart serve` in the background for audit collection: + +```yaml +- name: Start Rampart audit + run: | + rampart serve --background + +- name: Run agent + run: rampart wrap -- python agent.py + +- name: Upload audit + if: always() + uses: actions/upload-artifact@v4 + with: + name: rampart-audit + path: ~/.rampart/audit/*.jsonl +``` + +## See Also + +- [Native Ask Prompt](native-ask.md) — interactive approval for local development +- [Project Policies](project-policies.md) — team-shared rules in your repo +- [Wazuh Integration](wazuh-integration.md) — SIEM integration for CI audit trails diff --git a/docs-site/guides/prompt-injection.md b/docs-site/guides/prompt-injection.md index c6100aa0..f89d3dae 100644 --- a/docs-site/guides/prompt-injection.md +++ b/docs-site/guides/prompt-injection.md @@ -42,7 +42,7 @@ policies: match: tool: ["fetch", "web_search", "read"] rules: - - action: log + - action: log # Renamed to action: watch in v0.9.x. when: response_matches: - '(?i)ignore\s+previous\s+instructions' @@ -80,7 +80,7 @@ Documentation, security articles, and academic papers legitimately contain injec ```yaml rules: - - action: log + - action: log # Renamed to action: watch in v0.9.x. when: response_matches: - '(?i)ignore\s+previous\s+instructions' @@ -90,7 +90,7 @@ rules: message: "Possible prompt injection detected" ``` -Start with `action: log` and review a few days of audit history before promoting anything to `action: deny`. High-confidence, zero-ambiguity patterns (exact control tokens, structured roleplay markers) are safer to deny than natural-language instruction phrases. +Start with `action: log` (renamed to `action: watch` in v0.9.x) and review a few days of audit history before promoting anything to `action: deny`. High-confidence, zero-ambiguity patterns (exact control tokens, structured roleplay markers) are safer to deny than natural-language instruction phrases. ## Response Scanning Requirements diff --git a/docs-site/guides/wazuh-integration.md b/docs-site/guides/wazuh-integration.md new file mode 100644 index 00000000..297cabb6 --- /dev/null +++ b/docs-site/guides/wazuh-integration.md @@ -0,0 +1,221 @@ +--- +title: Wazuh Integration +description: Monitor AI agent activity in Wazuh — decode Rampart audit events, alert on denies, and correlate high-frequency blocks as possible prompt injection. +--- + +# Integrating Rampart with Wazuh + +Monitor AI agent activity and trigger alerts when Rampart blocks dangerous operations. + +## Overview + +Rampart logs every tool call decision to JSON files in `~/.rampart/audit/`. Wazuh can monitor these files, decode the events, and generate alerts based on deny/log actions — giving your SOC visibility into AI agent behavior alongside your existing security monitoring. + +## Architecture + +``` +AI Agent → Rampart (policy evaluation) → Audit Log (JSONL) + ↓ + Wazuh Agent (localfile) + ↓ + Wazuh Manager (rules) + ↓ + Wazuh Dashboard (alerts) +``` + +## Setup + +### 1. Configure Wazuh Agent to Monitor Audit Files + +Add to your Wazuh agent's `ossec.conf` (typically `/var/ossec/etc/ossec.conf`): + +```xml + + json + /home/YOUR_USER/.rampart/audit/*.jsonl + + +``` + +Restart the Wazuh agent: + +```bash +sudo systemctl restart wazuh-agent +``` + +### 2. Add Custom Decoder + +Create `/var/ossec/etc/decoders/rampart_decoder.xml` on the Wazuh manager: + +```xml + + ^{"id": + JSON_Decoder + +``` + +### 3. Add Custom Rules + +Create `/var/ossec/etc/rules/rampart_rules.xml` on the Wazuh manager: + +```xml + + + + + json + rampart + Rampart audit event + + + + + 100300 + allow + Rampart: AI agent tool call allowed - $(tool) - $(command) + rampart_allow + + + + + 100300 + watch + Rampart: AI agent tool call watched - $(tool) - $(command) + rampart_watch + + + + + 100300 + deny + Rampart: AI agent tool call BLOCKED - $(tool) - $(command) + rampart_deny + + + + + 100300 + ask + Rampart: AI agent tool call requires approval - $(tool) - $(command) + rampart_approval + + + + + 100303 + Rampart: Multiple AI agent tool calls blocked in 60 seconds — possible prompt injection or malicious behavior + rampart_attack + + + + + 100303 + protect-credentials|block-credential-exfil|encoding-sensitive-files + Rampart: AI agent attempted credential access - $(command) + rampart_credential_access + + + + + 100303 + block-exfil-domains|encoded-data-exfil|block-encoding-exfil + Rampart: AI agent attempted data exfiltration - $(command) + rampart_exfiltration + + + +``` + +Restart the Wazuh manager: + +```bash +sudo systemctl restart wazuh-manager +``` + +### 4. Verify + +Trigger a test deny event: + +```bash +# With rampart serve running +curl -s http://localhost:9090/v1/tool/exec \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"tool":"exec","params":{"command":"cat ~/.ssh/id_rsa"}}' +``` + +Check the Wazuh dashboard for a level 10+ alert from rule 100303 or 100306. + +## Syslog Output + +For direct syslog integration without file monitoring: + +```bash +# Send audit events to syslog (JSON format) +rampart serve --syslog localhost:514 + +# Send in CEF format (Common Event Format) for Splunk/QRadar/ArcSight +rampart serve --syslog localhost:514 --cef +``` + +CEF output format: + +``` +CEF:0|Rampart|PolicyEngine|0.1.7|deny|Destructive command blocked|8|src=claude-code cmd=rm -rf / policy=exec-safety +``` + +## Alert Levels + +| Rampart Action | Wazuh Level | Description | +|---------------|-------------|-------------| +| allow | 3 | Informational — normal operation | +| watch | 5 | Notable — flagged for review | +| ask | 8 | Security event — tool call queued for human approval | +| deny | 10 | Alert — blocked by policy | +| deny (credentials) | 12 | High alert — credential access attempt | +| deny (exfiltration) | 13 | Critical — data exfiltration attempt | +| 5+ denials in 60s | 12 | Correlation — possible prompt injection | + +## Dashboard Visualization + +In Wazuh Dashboard, create a custom visualization: + +- **Index pattern:** `wazuh-alerts-*` +- **Filter:** `rule.groups: rampart` +- **Useful fields:** `data.tool`, `data.action`, `data.command`, `data.policy_name`, `data.agent` + +## FIM Considerations + +If the Wazuh agent runs on the same machine as your AI agent, the agent's workspace can generate thousands of files (Go caches, node_modules, git objects, audit logs). This can exhaust Wazuh's default 100,000 file FIM limit. + +**Recommended syscheck configuration for AI agent hosts:** + +```xml + + + + yes + 500000 + + + + /home/*/.ssh + /home/*/.rampart/policies + + + /home + /etc,/usr/bin,/usr/sbin + + + node_modules|\.cache|\.npm|__pycache__|\.git/objects + +``` + +This gives you instant alerts on SSH key or Rampart policy tampering, scheduled coverage on everything else, and enough headroom to not hit limits. + +## Compatibility + +- Wazuh 4.x and later +- Works with Wazuh single-node and cluster deployments +- File monitoring works with any Wazuh agent (Linux, macOS) +- Syslog output works with any syslog-compatible SIEM diff --git a/docs-site/guides/windows.md b/docs-site/guides/windows.md new file mode 100644 index 00000000..d9f43cb5 --- /dev/null +++ b/docs-site/guides/windows.md @@ -0,0 +1,182 @@ +--- +title: Windows Setup Guide +description: Install and configure Rampart on Windows to protect Claude Code and other AI agents. +--- + +# Windows Setup Guide + +Rampart fully supports Windows for protecting Claude Code and other AI agents. + +## Quick Install + +```powershell +irm https://rampart.sh/install.ps1 | iex +``` + +This downloads the latest release, installs to `~\.rampart\bin`, adds it to your PATH, and offers to set up Claude Code hooks automatically. + +**Manual install:** Download the `.zip` from [GitHub Releases](https://github.com/peg/rampart/releases), extract `rampart.exe`, and add to your PATH. + +## Setup Claude Code + +```powershell +rampart setup claude-code +``` + +This adds hooks to `~\.claude\settings.json`. Claude Code will now route all Bash commands through Rampart. + +## That's It — You're Protected! + +After running `rampart setup claude-code`, dangerous commands are blocked immediately. **No need to run `rampart serve` for basic protection** — the hook evaluates policies locally. + +## Optional: Policy Server + +Run `rampart serve` if you want: +- **Live dashboard** — `rampart watch` shows real-time decisions +- **Approval flow** — `action: ask` policies need serve to handle human review +- **Centralized audit** — stream events to the dashboard + +```powershell +rampart serve +``` + +> **Note:** On Windows, `rampart serve` runs in the foreground. Keep the terminal window open, or use Task Scheduler/NSSM to run it at startup. + +## Verify Installation + +```powershell +# Check version +rampart version + +# Health check +rampart doctor + +# Test a command against your policy +rampart test "rm -rf /" +``` + +## Windows-Specific Notes + +### What Works + +| Feature | Status | +|---------|--------| +| `rampart serve` | ✅ Works (foreground only) | +| `rampart setup claude-code` | ✅ Works | +| `rampart hook` | ✅ Works | +| `rampart test` | ✅ Works | +| `rampart watch` | ✅ Works | +| `rampart mcp` | ✅ Works | +| Path-based policies | ✅ Works (auto-converts `\` to `/`) | + +### Limitations + +| Feature | Status | Notes | +|---------|--------|-------| +| `rampart serve --background` | ❌ Unix only | Uses fork/exec | +| `rampart serve stop` | ❌ Unix only | Uses SIGTERM | +| `rampart upgrade` | ✅ Works | Downloads `.zip` asset, replaces `rampart.exe` | +| `rampart wrap` | ❌ Unix only | Uses `$SHELL` | +| `rampart preload` | ❌ Linux only | Uses LD_PRELOAD | + +### Path Matching + +Rampart automatically normalizes Windows paths for policy matching: + +```yaml +# This policy works on both Windows and Unix: +- name: block-ssh-keys + match: + tool: [read] + rules: + - action: deny + when: + path_matches: + - "**/.ssh/id_*" + message: "SSH key access blocked" +``` + +`C:\Users\Trevor\.ssh\id_rsa` will match `**/.ssh/id_*` correctly. + +## Uninstall + +```powershell +rampart uninstall +``` + +This removes hooks from Claude Code and Cline, removes Rampart from your PATH, and prints instructions to delete the remaining files. + +**Manual cleanup** (if rampart command isn't working): +```powershell +# Delete Rampart files +Remove-Item -Recurse -Force ~\.rampart +``` + +Then remove `%USERPROFILE%\.rampart\bin` from PATH: **Settings → System → About → Advanced system settings → Environment Variables**. + +## Troubleshooting + +### Windows Defender / Antivirus Warnings + +Rampart is an unsigned binary that modifies other programs' configurations (Claude Code hooks). This may trigger security warnings: + +**SmartScreen "Windows protected your PC":** +1. Click "More info" +2. Click "Run anyway" + +**Windows Defender quarantine:** +1. Open Windows Security → Virus & threat protection +2. Click "Protection history" +3. Find Rampart, select "Restore" and "Allow on device" + +**Corporate antivirus blocking:** +Contact your IT team to whitelist `rampart.exe`, or install to a location your AV trusts. + +> **Why does this happen?** Rampart hooks into other programs and intercepts command execution — behaviors that look suspicious to antivirus heuristics. The binary is not code-signed (certificates cost ~$400/year). We're working on getting Rampart whitelisted with major AV vendors. + +### "rampart is not recognized" + +The installer refreshes PATH automatically, but if it doesn't work: +```powershell +$env:PATH = "$env:USERPROFILE\.rampart\bin;$env:PATH" +``` + +Or restart your terminal. + +### Installation fails with "Access Denied" + +If a previous install left files with broken permissions: + +```powershell +# Run as Administrator +takeown /f "$env:USERPROFILE\.rampart" /r /d y +icacls "$env:USERPROFILE\.rampart" /grant "$($env:USERNAME):F" /t +Remove-Item -Recurse -Force "$env:USERPROFILE\.rampart" + +# Then re-run installer +irm https://rampart.sh/install.ps1 | iex +``` + +### Claude Code not seeing hooks + +1. Verify hooks are installed: `rampart doctor` +2. Check settings file exists: `Test-Path ~\.claude\settings.json` +3. Re-run setup: `rampart setup claude-code --force` + +### Policy not blocking commands + +1. Make sure `rampart serve` is running +2. Check the serve URL matches (default: `http://localhost:9090`) +3. Test directly: `rampart test "your-command"` + +## Known Behavior + +### `action: ask` in `--dangerously-skip-permissions` mode + +`action: ask` shows the native approval prompt even when Claude Code is launched with `--dangerously-skip-permissions`. Claude Code honors hook-returned permission decisions regardless of the bypass flag. + +## Next Steps + +- [Customizing Policy](customizing-policy.md) — customize what's allowed +- [Native Ask Prompt](native-ask.md) — inline approval dialogs for sensitive commands +- [Live Dashboard](../getting-started/how-it-works.md) — monitor in real-time with `rampart watch` diff --git a/docs-site/integrations/cline.md b/docs-site/integrations/cline.md index 29a26d2e..f4e81ccf 100644 --- a/docs-site/integrations/cline.md +++ b/docs-site/integrations/cline.md @@ -48,7 +48,7 @@ rampart watch ## Start in Monitor Mode -Not sure about your policies yet? Set your policy's `default_action: allow` and use `action: log` rules instead of `deny` — everything gets logged but nothing gets blocked. Check `rampart watch` to see what would be caught, then switch rules to `deny` when you're confident. +Not sure about your policies yet? Set your policy's `default_action: allow` and use `action: log` rules instead of `deny` — everything gets logged but nothing gets blocked. In v0.9.x, `action: log` was renamed to `action: watch`. Check `rampart watch` to see what would be caught, then switch rules to `deny` when you're confident. ## Troubleshooting diff --git a/docs-site/reference/cli-commands.md b/docs-site/reference/cli-commands.md index 3d2e5e2e..218d4c66 100644 --- a/docs-site/reference/cli-commands.md +++ b/docs-site/reference/cli-commands.md @@ -213,7 +213,7 @@ The token is saved to `~/.rampart/token` and embedded in the service file (mode | `--config-dir` | _(none)_ | Directory of additional policy YAML files | | `--audit-dir` | `~/.rampart/audit` | Directory for audit logs | | `--mode` | `enforce` | Enforcement mode: `enforce`, `monitor`, or `disabled` | -| `--approval-timeout` | `5m` | How long approvals stay pending before expiring | +| `--approval-timeout` | `2m` | How long approvals stay pending before expiring | | `--token` | _(auto-generated)_ | Override `RAMPART_TOKEN` for the service | | `--force` | `false` | Overwrite an existing service installation | diff --git a/docs-site/reference/policy-schema.md b/docs-site/reference/policy-schema.md index 37bb0e84..523d399e 100644 --- a/docs-site/reference/policy-schema.md +++ b/docs-site/reference/policy-schema.md @@ -298,7 +298,7 @@ policies: match: tool: ["exec"] rules: - - action: log + - action: log # Renamed to action: watch in v0.9.x. when: command_matches: ["curl *", "wget *"] message: "Network command logged" diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index cedc1d8e..78f4aeba 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -55,8 +55,7 @@ Common decision/action values across responses: - `allow` - `deny` - `watch` -- `ask` -- `require_approval` +- `ask` (approval-required decision; `require_approval` was removed in v0.9.9) - `approved` / `denied` / `always_allowed` (approval resolution audit context) ## Endpoints diff --git a/docs/guides/openclaw-approval.md b/docs/guides/openclaw-approval.md index a6e46c95..26a5470b 100644 --- a/docs/guides/openclaw-approval.md +++ b/docs/guides/openclaw-approval.md @@ -97,25 +97,25 @@ rampart doctor --fix sudo rampart setup openclaw --patch-tools --force ``` -Check bridge connection: +## Verify it's working + +Check that the bridge connected successfully after starting OpenClaw: ```bash -journalctl --user -u rampart-proxy -n 20 | grep bridge -# Should show: "bridge: handshake complete" with no disconnect +journalctl --user -u rampart-serve -n 20 | grep "bridge: connected" ``` +You should see a line like `bridge: connected to gateway ws://localhost:9000`. No output means the bridge hasn't connected — check that `rampart serve` is running and OpenClaw's gateway is up. + +> **Note:** `rampart doctor` will soon include an explicit `ask: on-miss` check to verify the OpenClaw config is set correctly. Once that fix lands, `rampart doctor` will flag misconfigured setups automatically. + ## OpenClaw config recommendation For the best experience, set `ask` to `on-miss` in `~/.openclaw/openclaw.json`: ```json { - "tools": { - "exec": { - "security": "full", - "ask": "on-miss" - } - } + "ask": "on-miss" } ``` diff --git a/install.sh b/install.sh index 81fb5b62..b6a445dd 100755 --- a/install.sh +++ b/install.sh @@ -2,12 +2,14 @@ # Rampart install script # Usage: curl -fsSL https://rampart.sh/install | sh # curl -fsSL https://rampart.sh/install | sh -s -- --version v0.1.0 +# curl -fsSL https://rampart.sh/install | sh -s -- --auto-setup set -e REPO="peg/rampart" INSTALL_DIR="/usr/local/bin" BINARY="rampart" VERSION="" +AUTO_SETUP="${RAMPART_AUTO_SETUP:-0}" # Colors (if terminal supports them). if [ -t 1 ]; then @@ -29,6 +31,7 @@ while [ $# -gt 0 ]; do case "$1" in --version) VERSION="$2"; shift 2 ;; --version=*) VERSION="${1#--version=}"; shift ;; + --auto-setup) AUTO_SETUP=1; shift ;; *) error "Unknown option: $1" ;; esac done @@ -129,3 +132,61 @@ else printf "Add it: ${BOLD}export PATH=\"${INSTALL_DIR}:\$PATH\"${RESET}\n" printf "Then run: ${BOLD}rampart quickstart${RESET}\n" fi + +# Detect AI agents and suggest setup commands. +detect_agents_and_suggest() { + printf "\n" + + # Detect OpenClaw + OPENCLAW_FOUND=0 + if command -v openclaw >/dev/null 2>&1; then + OPENCLAW_FOUND=1 + elif [ -f "$HOME/.local/bin/openclaw" ] || [ -f "/usr/local/bin/openclaw" ] || [ -f "/usr/bin/openclaw" ]; then + OPENCLAW_FOUND=1 + fi + + # Detect Claude Code (claude CLI) + CLAUDE_FOUND=0 + if command -v claude >/dev/null 2>&1; then + CLAUDE_FOUND=1 + elif [ -f "$HOME/.claude/settings.json" ]; then + CLAUDE_FOUND=1 + fi + + if [ "$OPENCLAW_FOUND" -eq 1 ] || [ "$CLAUDE_FOUND" -eq 1 ]; then + printf "${GREEN}${BOLD}✓ AI agent(s) detected!${RESET}\n\n" + fi + + if [ "$OPENCLAW_FOUND" -eq 1 ]; then + if [ "$AUTO_SETUP" = "1" ]; then + printf "${GREEN}▸${RESET} Auto-setup: protecting OpenClaw...\n" + rampart setup openclaw 2>&1 || printf "${YELLOW} ↳ Auto-setup failed — run manually: rampart setup openclaw${RESET}\n" + else + printf " Run this to protect your OpenClaw agent:\n" + printf " ${BOLD}rampart setup openclaw${RESET}\n" + printf "\n" + fi + fi + + if [ "$CLAUDE_FOUND" -eq 1 ]; then + if [ "$AUTO_SETUP" = "1" ]; then + printf "${GREEN}▸${RESET} Auto-setup: protecting Claude Code...\n" + rampart setup claude-code 2>&1 || printf "${YELLOW} ↳ Auto-setup failed — run manually: rampart setup claude-code${RESET}\n" + else + printf " Run this to protect Claude Code:\n" + printf " ${BOLD}rampart setup claude-code${RESET}\n" + printf "\n" + fi + fi + + if [ "$OPENCLAW_FOUND" -eq 0 ] && [ "$CLAUDE_FOUND" -eq 0 ]; then + printf " To protect an AI agent, run:\n" + printf " ${BOLD}rampart setup openclaw${RESET} — for OpenClaw\n" + printf " ${BOLD}rampart setup claude-code${RESET} — for Claude Code\n" + printf "\n" + fi +} + +if command -v rampart >/dev/null 2>&1; then + detect_agents_and_suggest +fi diff --git a/internal/approval/store.go b/internal/approval/store.go index ac23a05c..52b830a7 100644 --- a/internal/approval/store.go +++ b/internal/approval/store.go @@ -20,9 +20,13 @@ package approval import ( + "bufio" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" + "log/slog" + "os" "sort" "sync" "time" @@ -95,6 +99,27 @@ type Request struct { done chan struct{} } +// persistRecord is the on-disk representation of an approval request. +// Uses flat string fields to avoid circular JSON dependencies on engine types. +type persistRecord struct { + ID string `json:"id"` + Tool string `json:"tool"` + Agent string `json:"agent"` + Session string `json:"session,omitempty"` + RunID string `json:"run_id,omitempty"` + Command string `json:"command,omitempty"` + Params map[string]any `json:"params,omitempty"` + Input map[string]any `json:"input,omitempty"` + MatchedPolicies []string `json:"matched_policies,omitempty"` + Message string `json:"message,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + ResolvedAt time.Time `json:"resolved_at,omitempty"` + ResolvedBy string `json:"resolved_by,omitempty"` + Status string `json:"status"` + Persisted bool `json:"persisted,omitempty"` +} + // Store manages pending approval requests. type Store struct { mu sync.Mutex @@ -103,6 +128,8 @@ type Store struct { timeout time.Duration onExpire func(*Request) stop chan struct{} + persistFile string + logger *slog.Logger } // Option configures a Store. @@ -122,6 +149,22 @@ func WithExpireCallback(fn func(*Request)) Option { } } +// WithPersistenceFile sets the path of the JSONL file used to persist +// pending approvals across server restarts. +// If empty, persistence is disabled (in-memory only). +func WithPersistenceFile(path string) Option { + return func(s *Store) { + s.persistFile = path + } +} + +// WithLogger sets the logger for the store. +func WithLogger(l *slog.Logger) Option { + return func(s *Store) { + s.logger = l + } +} + // NewStore creates a new approval store. // Starts a background goroutine that cleans up resolved requests every 5 minutes. func NewStore(opts ...Option) *Store { @@ -130,11 +173,17 @@ func NewStore(opts ...Option) *Store { autoApproveRuns: make(map[string]time.Time), timeout: 2 * time.Minute, // Match OpenClaw's DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS (130s) stop: make(chan struct{}), + logger: slog.Default(), } for _, opt := range opts { opt(s) } + // Load persisted approvals from disk (if a file is configured). + if s.persistFile != "" { + s.loadFromDisk() + } + // Periodic cleanup of resolved/expired entries. go func() { ticker := time.NewTicker(5 * time.Minute) @@ -220,6 +269,13 @@ func (s *Store) Create(call engine.ToolCall, decision engine.Decision) (*Request s.pending[req.ID] = req + // Persist to disk. + if s.persistFile != "" { + if err := s.appendToDisk(req); err != nil { + s.logger.Warn("approval: failed to persist new approval", "id", req.ID, "error", err) + } + } + // Start expiry timer. go s.watchExpiry(req) @@ -251,6 +307,14 @@ func (s *Store) Resolve(id string, approved bool, resolvedBy string, persist boo req.Persisted = approved && persist // Only set if approved with persist=true close(req.done) + + // Rewrite the persistence file to reflect the updated status. + if s.persistFile != "" { + if err := s.rewriteDisk(); err != nil { + s.logger.Warn("approval: failed to rewrite persistence file after resolve", "id", id, "error", err) + } + } + return nil } @@ -312,6 +376,13 @@ func (s *Store) Cleanup(olderThan time.Duration) int { s.cleanAutoApproveCache() + // Prune the on-disk file to match in-memory state. + if removed > 0 && s.persistFile != "" { + if err := s.rewriteDisk(); err != nil { + s.logger.Warn("approval: failed to rewrite persistence file after cleanup", "error", err) + } + } + return removed } @@ -363,7 +434,184 @@ func (s *Store) watchExpiry(req *Request) { if s.onExpire != nil { go s.onExpire(req) } + + // Rewrite persistence file after timeout expiry. + if s.persistFile != "" { + if err := s.rewriteDisk(); err != nil { + s.logger.Warn("approval: failed to rewrite persistence file after expiry", "id", req.ID, "error", err) + } + } } s.mu.Unlock() } } + +// --- Persistence helpers --- + +// toRecord converts a Request to its on-disk representation. +func toRecord(req *Request) persistRecord { + return persistRecord{ + ID: req.ID, + Tool: req.Call.Tool, + Agent: req.Call.Agent, + Session: req.Call.Session, + RunID: req.Call.RunID, + Command: req.Call.Command(), + Params: req.Call.Params, + Input: req.Call.Input, + MatchedPolicies: req.Decision.MatchedPolicies, + Message: req.Decision.Message, + CreatedAt: req.CreatedAt, + ExpiresAt: req.ExpiresAt, + ResolvedAt: req.ResolvedAt, + ResolvedBy: req.ResolvedBy, + Status: req.Status.String(), + Persisted: req.Persisted, + } +} + +// fromRecord reconstructs an in-memory Request from a persist record. +// Returns (nil, false) if the record should be discarded (expired or non-pending). +func fromRecord(rec persistRecord) (*Request, bool) { + // Only restore truly pending approvals. + if rec.Status != "pending" { + return nil, false + } + // Discard expired approvals. + if time.Now().After(rec.ExpiresAt) { + return nil, false + } + + call := engine.ToolCall{ + Tool: rec.Tool, + Agent: rec.Agent, + Session: rec.Session, + RunID: rec.RunID, + Params: rec.Params, + Input: rec.Input, + } + if call.Params == nil { + call.Params = make(map[string]any) + } + decision := engine.Decision{ + Action: engine.ActionRequireApproval, + MatchedPolicies: rec.MatchedPolicies, + Message: rec.Message, + } + + req := &Request{ + ID: rec.ID, + Call: call, + Decision: decision, + Status: StatusPending, + CreatedAt: rec.CreatedAt, + ExpiresAt: rec.ExpiresAt, + dedupKey: dedupKey(call), + done: make(chan struct{}), + } + return req, true +} + +// loadFromDisk reads the persistence file and restores pending (non-expired) +// approvals to the in-memory map. Must be called before the store is used. +// Errors are logged but never fatal — a missing or corrupt file is safe to ignore. +func (s *Store) loadFromDisk() { + f, err := os.Open(s.persistFile) + if err != nil { + if !os.IsNotExist(err) { + s.logger.Warn("approval: could not open persistence file", "path", s.persistFile, "error", err) + } + return + } + defer f.Close() + + restored := 0 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var rec persistRecord + if err := json.Unmarshal(line, &rec); err != nil { + s.logger.Warn("approval: skipping malformed persistence record", "error", err) + continue + } + req, ok := fromRecord(rec) + if !ok { + continue + } + s.pending[req.ID] = req + go s.watchExpiry(req) + restored++ + } + if err := scanner.Err(); err != nil { + s.logger.Warn("approval: error reading persistence file", "path", s.persistFile, "error", err) + } + if restored > 0 { + s.logger.Info("approval: restored pending approvals from disk", "count", restored, "path", s.persistFile) + } +} + +// appendToDisk appends a single approval record to the JSONL file. +// Must be called with s.mu held. +func (s *Store) appendToDisk(req *Request) error { + if err := os.MkdirAll(dirOf(s.persistFile), 0o755); err != nil { + return fmt.Errorf("approval: create dir: %w", err) + } + f, err := os.OpenFile(s.persistFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return fmt.Errorf("approval: open persistence file: %w", err) + } + defer f.Close() + + data, err := json.Marshal(toRecord(req)) + if err != nil { + return fmt.Errorf("approval: marshal record: %w", err) + } + _, err = fmt.Fprintf(f, "%s\n", data) + return err +} + +// rewriteDisk atomically rewrites the persistence file with the current +// in-memory state. Only pending (non-expired) approvals are written. +// Must be called with s.mu held. +func (s *Store) rewriteDisk() error { + if err := os.MkdirAll(dirOf(s.persistFile), 0o755); err != nil { + return fmt.Errorf("approval: create dir: %w", err) + } + + tmp := s.persistFile + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("approval: open tmp file: %w", err) + } + + enc := json.NewEncoder(f) + for _, req := range s.pending { + // Only persist pending approvals; resolved/expired ones are transient. + if req.Status != StatusPending { + continue + } + if err := enc.Encode(toRecord(req)); err != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("approval: encode record: %w", err) + } + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, s.persistFile) +} + +// dirOf returns the directory portion of a file path. +func dirOf(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' || path[i] == '\\' { + return path[:i] + } + } + return "." +} diff --git a/internal/approval/store_test.go b/internal/approval/store_test.go index f827920f..7476fe0c 100644 --- a/internal/approval/store_test.go +++ b/internal/approval/store_test.go @@ -14,6 +14,8 @@ package approval import ( + "os" + "path/filepath" "testing" "time" @@ -179,3 +181,104 @@ func TestWaitForResolution(t *testing.T) { t.Fatal("timed out waiting for resolution") } } + +func TestApprovalStorePersistence(t *testing.T) { + // Use a temp file for persistence. + f, err := os.CreateTemp(t.TempDir(), "approvals-*.jsonl") + require.NoError(t, err) + f.Close() + persistFile := f.Name() + + // 1. Create a store with a pending approval. + store1 := NewStore(WithPersistenceFile(persistFile)) + req, err := store1.Create(testCall(), testDecision()) + require.NoError(t, err) + assert.Equal(t, StatusPending, req.Status) + store1.Close() + + // 2. Create a NEW store pointing to the same file. + store2 := NewStore(WithPersistenceFile(persistFile)) + defer store2.Close() + + // 3. Verify the approval is restored and still pending. + restored, ok := store2.Get(req.ID) + require.True(t, ok, "approval should be restored from disk") + assert.Equal(t, StatusPending, restored.Status) + assert.Equal(t, req.ID, restored.ID) + assert.Equal(t, req.Call.Tool, restored.Call.Tool) + assert.Equal(t, req.Call.Agent, restored.Call.Agent) + + // Also verify it shows up in List(). + pending := store2.List() + assert.Len(t, pending, 1) + assert.Equal(t, req.ID, pending[0].ID) +} + +func TestApprovalStorePersistenceExpiredNotRestored(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "approvals-expired-*.jsonl") + require.NoError(t, err) + f.Close() + persistFile := f.Name() + + // Create a store with a very short timeout so approval expires immediately. + store1 := NewStore( + WithPersistenceFile(persistFile), + WithTimeout(1*time.Millisecond), + ) + req, err := store1.Create(testCall(), testDecision()) + require.NoError(t, err) + assert.NotEmpty(t, req.ID) + // Wait for expiry. + time.Sleep(50 * time.Millisecond) + store1.Close() + + // Create a new store: expired approval should NOT be restored. + store2 := NewStore(WithPersistenceFile(persistFile)) + defer store2.Close() + + _, ok := store2.Get(req.ID) + assert.False(t, ok, "expired approval should not be restored from disk") + assert.Empty(t, store2.List(), "no pending approvals should be restored") +} + +func TestApprovalStorePersistenceResolvedNotRestored(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "approvals-resolved-*.jsonl") + require.NoError(t, err) + f.Close() + persistFile := f.Name() + + store1 := NewStore(WithPersistenceFile(persistFile)) + req, err := store1.Create(testCall(), testDecision()) + require.NoError(t, err) + + // Resolve the approval. + err = store1.Resolve(req.ID, true, "cli", false) + require.NoError(t, err) + store1.Close() + + // Create a new store: resolved approval should NOT be restored. + store2 := NewStore(WithPersistenceFile(persistFile)) + defer store2.Close() + + _, ok := store2.Get(req.ID) + assert.False(t, ok, "resolved approval should not be restored from disk") + assert.Empty(t, store2.List()) +} + +func TestApprovalStorePersistenceMissingFile(t *testing.T) { + // Point to a nonexistent file — should not panic or error. + persistFile := filepath.Join(t.TempDir(), "does-not-exist.jsonl") + store := NewStore(WithPersistenceFile(persistFile)) + defer store.Close() + + // Should work fine with an empty store. + assert.Empty(t, store.List()) + + // Create should work and create the file. + req, err := store.Create(testCall(), testDecision()) + require.NoError(t, err) + assert.Equal(t, StatusPending, req.Status) + + _, err = os.Stat(persistFile) + assert.NoError(t, err, "persistence file should be created on first write") +} diff --git a/internal/bridge/openclaw.go b/internal/bridge/openclaw.go index 3dbf10c1..425280fc 100644 --- a/internal/bridge/openclaw.go +++ b/internal/bridge/openclaw.go @@ -58,8 +58,8 @@ type OpenClawBridge struct { gatewayURL string token string serveURL string - logger *slog.Logger sink audit.AuditSink + logger *slog.Logger reconnectInterval time.Duration @@ -86,12 +86,11 @@ type Config struct { // ReconnectInterval is how long to wait before reconnecting after a disconnect. ReconnectInterval time.Duration + // AuditSink is the audit sink for logging bridge-evaluated tool calls. + AuditSink audit.AuditSink + // Logger is the structured logger. Logger *slog.Logger - - // AuditSink is an optional sink to write audit events for bridge-evaluated - // approvals. If nil, audit events are skipped for bridge traffic. - AuditSink audit.AuditSink } // NewOpenClawBridge creates a new bridge. @@ -111,11 +110,11 @@ func NewOpenClawBridge(eng *engine.Engine, cfg Config) *OpenClawBridge { gatewayURL: cfg.GatewayURL, token: cfg.GatewayToken, serveURL: cfg.ServeURL, - logger: cfg.Logger, sink: cfg.AuditSink, + logger: cfg.Logger, reconnectInterval: cfg.ReconnectInterval, - pending: make(map[string]chan struct{}), - pendingCommands: make(map[string]string), + pending: make(map[string]chan struct{}), + pendingCommands: make(map[string]string), } } @@ -242,53 +241,29 @@ func (b *OpenClawBridge) sendConnect(conn *websocket.Conn) error { return fmt.Errorf("expected connect.challenge, got type=%s event=%s", challengeFrame.Type, challengeFrame.Event) } - // Extract nonce from challenge payload. - var challengePayload struct { - Nonce string `json:"nonce"` - } - if len(challengeFrame.Payload) > 0 { - _ = json.Unmarshal(challengeFrame.Payload, &challengePayload) - } - - // Step 2: send connect request with device identity so the gateway preserves - // our operator.approvals scope. Without device identity, the gateway silently - // strips all scopes via clearUnboundScopes(), preventing exec.approval.* events. - scopes := []string{"operator.approvals"} - - connectParams := map[string]any{ - "minProtocol": 3, - "maxProtocol": 3, - "client": map[string]any{ - "id": "gateway-client", - "displayName": "Rampart Bridge", - "version": "0.0.1", - "platform": runtime.GOOS, - "mode": "backend", - }, - "auth": map[string]any{ - "token": b.token, - }, - "scopes": scopes, - "role": "operator", - "caps": []string{}, - } - - // Include device identity if available — required to preserve scopes. - if identity, err := loadOpenClawDeviceIdentity(); err != nil { - b.logger.Warn("bridge: device identity unavailable — scopes may be stripped by gateway", "error", err) - } else if devicePayload, err := identity.buildDeviceAuthPayload(challengePayload.Nonce, b.token, scopes); err != nil { - b.logger.Warn("bridge: device auth payload failed — scopes may be stripped by gateway", "error", err) - } else { - connectParams["device"] = devicePayload - b.logger.Debug("bridge: device identity loaded", "device_id", identity.DeviceID) - } - + // Step 2: send connect request. reqID := uuid.New().String() frame := gatewayRequest{ Type: "req", ID: reqID, Method: "connect", - Params: connectParams, + Params: map[string]any{ + "minProtocol": 3, + "maxProtocol": 3, + "client": map[string]any{ + "id": "gateway-client", + "displayName": "Rampart Bridge", + "version": "0.0.1", + "platform": runtime.GOOS, + "mode": "backend", + }, + "auth": map[string]any{ + "token": b.token, + }, + "scopes": []string{"operator.approvals"}, + "role": "operator", + "caps": []string{}, + }, } data, err := json.Marshal(frame) @@ -320,7 +295,6 @@ func (b *OpenClawBridge) sendConnect(conn *websocket.Conn) error { return fmt.Errorf("unexpected connect response type: %s", resFrame.Type) } - b.logger.Info("bridge: handshake complete", "client_id", "rampart-bridge", "scopes", []string{"operator.approvals"}) return nil } @@ -334,6 +308,7 @@ func (b *OpenClawBridge) handleFrame(ctx context.Context, conn *websocket.Conn, switch frame.Type { case "event": + b.logger.Debug("bridge: received event", "event", frame.Event) b.handleEvent(ctx, conn, frame) case "res", "err": // Responses to our requests — currently fire-and-forget for resolveApproval. @@ -342,8 +317,6 @@ func (b *OpenClawBridge) handleFrame(ctx context.Context, conn *websocket.Conn, } } - - // handleEvent handles an incoming event frame. func (b *OpenClawBridge) handleEvent(ctx context.Context, conn *websocket.Conn, frame gatewayFrame) { switch frame.Event { @@ -353,6 +326,13 @@ func (b *OpenClawBridge) handleEvent(ctx context.Context, conn *websocket.Conn, b.logger.Error("bridge: failed to parse approval request", "error", err) return } + // Store the command immediately so allow-always writeback works + // regardless of who ultimately resolves the approval (Rampart or OpenClaw native flow). + if req.ID != "" && req.command() != "" { + b.pendingMu.Lock() + b.pendingCommands[req.ID] = req.command() + b.pendingMu.Unlock() + } go b.handleApprovalRequested(ctx, conn, req) case "exec.approval.resolved": @@ -375,14 +355,6 @@ func (b *OpenClawBridge) handleEvent(ctx context.Context, conn *websocket.Conn, if resolved.Decision == "allow-always" && cmd != "" { go b.writeAllowAlwaysRule(cmd) } - - // Cross-resolve: if the shim created a matching Rampart HTTP approval - // for the same command, resolve it so the shim poll unblocks. - // This connects the Discord approval UI to Claude Code running via shim. - if cmd != "" && b.serveURL != "" && resolved.Decision != "" { - approved := resolved.Decision != "deny" - go b.crossResolveShimApproval(cmd, approved, resolved.Decision == "allow-always") - } } } } @@ -393,7 +365,7 @@ func (b *OpenClawBridge) handleApprovalRequested(ctx context.Context, conn *webs call := engine.ToolCall{ Tool: "exec", Agent: req.agentID(), - Session: req.Request.SessionKey, + Session: req.sessionKey(), Params: map[string]any{ "command": req.command(), }, @@ -412,7 +384,7 @@ func (b *OpenClawBridge) handleApprovalRequested(ctx context.Context, conn *webs "duration", evalDuration, ) - // Write an audit event so bridge-evaluated commands appear in the JSONL trail. + // Write audit event so bridge-evaluated commands appear in the JSONL trail. if b.sink != nil { ev := audit.Event{ ID: audit.NewEventID(), @@ -436,23 +408,15 @@ func (b *OpenClawBridge) handleApprovalRequested(ctx context.Context, conn *webs switch decision.Action { case engine.ActionAllow, engine.ActionWatch: b.resolveApproval(conn, req.ID, "allow-once") + b.cleanPendingCommand(req.ID) case engine.ActionDeny: b.resolveApproval(conn, req.ID, "deny") + b.cleanPendingCommand(req.ID) case engine.ActionRequireApproval, engine.ActionAsk: - // For OpenClaw bridge approvals, defer to OpenClaw's own approval UI - // (Discord/Telegram embed). Don't escalate to Rampart serve — that creates - // a competing timer and confusing dual-approval UX. - // - // Register the command so that when the user clicks "Always Allow" in - // Discord, the exec.approval.resolved handler can write the rule to - // user-overrides.yaml. - b.pendingMu.Lock() - b.pendingCommands[req.ID] = req.command() - b.pendingMu.Unlock() - b.logger.Info("bridge: deferring to OpenClaw approval UI", - "id", req.ID, "command", req.command(), "policy", decision.Message) + // Escalate to Rampart serve for human review. + b.escalateToServe(ctx, conn, req, decision) case engine.ActionWebhook: // Webhook actions delegate to an external system. @@ -497,9 +461,8 @@ func (b *OpenClawBridge) sendResolve(conn *websocket.Conn, approvalID, decision ID: uuid.New().String(), Method: "exec.approval.resolve", Params: map[string]any{ - "id": approvalID, - "decision": decision, - "resolvedBy": "rampart-policy", + "id": approvalID, + "decision": decision, }, } @@ -519,17 +482,18 @@ func (b *OpenClawBridge) escalateToServe(ctx context.Context, conn *websocket.Co b.logger.Warn("bridge: approval requires human review — escalating to serve", "id", req.ID, "command", req.command(), "serve_url", b.serveURL) - // Register cancellation channel and store command for allow-always writeback. + // Register cancellation channel. Command already stored in pendingCommands + // at exec.approval.requested time so allow-always writeback works for all flows. cancelCh := make(chan struct{}) b.pendingMu.Lock() b.pending[req.ID] = cancelCh - b.pendingCommands[req.ID] = req.command() b.pendingMu.Unlock() defer func() { b.pendingMu.Lock() delete(b.pending, req.ID) - delete(b.pendingCommands, req.ID) + // pendingCommands is cleaned up by the resolved event handler + // so allow-always writeback works even after escalation completes. b.pendingMu.Unlock() }() @@ -537,7 +501,7 @@ func (b *OpenClawBridge) escalateToServe(ctx context.Context, conn *websocket.Co "tool": "exec", "command": req.command(), "agent": req.agentID(), - "session": req.Request.SessionKey, + "session": req.sessionKey(), "message": decision.Message, }) @@ -577,7 +541,7 @@ func (b *OpenClawBridge) escalateToServe(ctx context.Context, conn *websocket.Co // Poll for the Rampart approval decision. const pollInterval = 2 * time.Second - const pollTimeout = 150 * time.Second // Slightly over OpenClaw's 130s approval window + const pollTimeout = 5 * time.Minute deadline := time.Now().Add(pollTimeout) for { @@ -623,93 +587,17 @@ func (b *OpenClawBridge) escalateToServe(ctx context.Context, conn *websocket.Co } } +// cleanPendingCommand removes a command from pendingCommands after auto-resolution. +// For escalated commands, cleanup happens in the resolved event handler instead. +func (b *OpenClawBridge) cleanPendingCommand(id string) { + b.pendingMu.Lock() + delete(b.pendingCommands, id) + b.pendingMu.Unlock() +} + // writeAllowAlwaysRule appends an allow rule for the given command to // ~/.rampart/policies/user-overrides.yaml and hot-reloads the engine. // This is called when a human clicks "Always Allow" in the OpenClaw approval UI. -// crossResolveShimApproval finds a pending Rampart HTTP approval matching the -// given command and resolves it. This connects the Discord approval UI (OpenClaw -// gateway flow) to the shim polling flow (Claude Code running inside OpenClaw). -// -// Without this, a Claude Code command intercepted by the shim creates a pending -// Rampart HTTP approval that nobody resolves — the Discord embed fires via -// OpenClaw's native flow but the shim poll just times out. -func (b *OpenClawBridge) crossResolveShimApproval(command string, approved, persist bool) { - token := os.Getenv("RAMPART_TOKEN") - - // Step 1: list pending Rampart HTTP approvals. - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - listReq, err := http.NewRequestWithContext(ctx, "GET", b.serveURL+"/v1/approvals", nil) - if err != nil { - return - } - if token != "" { - listReq.Header.Set("Authorization", "Bearer "+token) - } - listResp, err := http.DefaultClient.Do(listReq) - if err != nil || listResp.StatusCode != http.StatusOK { - return - } - defer listResp.Body.Close() - - var result struct { - Approvals []struct { - ID string `json:"id"` - Command string `json:"command"` - Status string `json:"status"` - } `json:"approvals"` - } - if err := json.NewDecoder(listResp.Body).Decode(&result); err != nil { - return - } - - // Step 2: find matching pending approval by command. - var matchID string - for _, item := range result.Approvals { - if item.Status == "pending" && item.Command == command { - matchID = item.ID - break - } - } - if matchID == "" { - return // no matching shim approval — normal for OpenClaw-native exec - } - - // Step 3: resolve the matching approval. - body, _ := json.Marshal(map[string]any{ - "approved": approved, - "persist": persist, - "resolved_by": "openclaw-discord", - }) - - ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel2() - - resolveReq, err := http.NewRequestWithContext(ctx2, "POST", - b.serveURL+"/v1/approvals/"+matchID+"/resolve", - bytes.NewReader(body)) - if err != nil { - return - } - resolveReq.Header.Set("Content-Type", "application/json") - if token != "" { - resolveReq.Header.Set("Authorization", "Bearer "+token) - } - - resp, err := http.DefaultClient.Do(resolveReq) - if err != nil { - b.logger.Debug("bridge: cross-resolve shim approval failed", "error", err) - return - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusOK { - b.logger.Info("bridge: cross-resolved shim approval via Discord", - "approval_id", matchID, "command", command, "approved", approved, "persist", persist) - } -} - func (b *OpenClawBridge) writeAllowAlwaysRule(command string) { home, err := os.UserHomeDir() if err != nil { @@ -727,12 +615,6 @@ func (b *OpenClawBridge) writeAllowAlwaysRule(command string) { rule := fmt.Sprintf("\n- name: %s\n match:\n tool: exec\n rules:\n - when:\n command_matches:\n - %q\n action: allow\n message: \"User allowed (always)\"\n", ruleName, command) - // Ensure the policies directory exists before writing. - if err := os.MkdirAll(filepath.Dir(overridesPath), 0o750); err != nil { - b.logger.Error("bridge: allow-always: create policies dir", "error", err) - return - } - // Read existing file or create with header. var existing string data, err := os.ReadFile(overridesPath) @@ -747,6 +629,10 @@ func (b *OpenClawBridge) writeAllowAlwaysRule(command string) { } } + if err := os.MkdirAll(filepath.Dir(overridesPath), 0o700); err != nil { + b.logger.Error("bridge: allow-always: create policies dir", "error", err) + return + } if err := os.WriteFile(overridesPath, []byte(existing+rule), 0o600); err != nil { b.logger.Error("bridge: allow-always: write user-overrides.yaml", "error", err) return @@ -791,7 +677,6 @@ type gatewayFrame struct { } // approvalRequestParams is the payload of an exec.approval.requested event. -// The gateway broadcasts: {id, request: {command, agentId, sessionKey, cwd, ...}, createdAtMs, expiresAtMs} type approvalRequestParams struct { ID string `json:"id"` Request struct { @@ -802,11 +687,9 @@ type approvalRequestParams struct { } `json:"request"` } -// command returns the command string from the nested request object. -func (p approvalRequestParams) command() string { return p.Request.Command } - -// agentID returns the agent ID from the nested request object. -func (p approvalRequestParams) agentID() string { return p.Request.AgentID } +func (r approvalRequestParams) command() string { return r.Request.Command } +func (r approvalRequestParams) agentID() string { return r.Request.AgentID } +func (r approvalRequestParams) sessionKey() string { return r.Request.SessionKey } // --- Discovery helpers --- diff --git a/internal/bridge/openclaw_test.go b/internal/bridge/openclaw_test.go index 00d94b7c..6c824041 100644 --- a/internal/bridge/openclaw_test.go +++ b/internal/bridge/openclaw_test.go @@ -28,6 +28,7 @@ import ( "github.com/gorilla/websocket" "github.com/peg/rampart/internal/audit" "github.com/peg/rampart/internal/engine" + "github.com/peg/rampart/internal/policy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -105,7 +106,7 @@ func (mg *mockGateway) sendApprovalRequest(id, command, agent string) { SessionKey string `json:"sessionKey"` } payload, _ := json.Marshal(struct { - ID string `json:"id"` + ID string `json:"id"` Request requestInner `json:"request"` }{ ID: id, @@ -567,3 +568,38 @@ func (s *testAuditSink) Write(ev audit.Event) error { } func (s *testAuditSink) Flush() error { return nil } func (s *testAuditSink) Close() error { return nil } + +func TestBuildAllowPattern(t *testing.T) { + tests := []struct { + input string + want string + }{ + // 3+ tokens: replace last arg with * + {"sudo apt-get install nmap", "sudo apt-get install *"}, + {"kubectl apply -f prod.yaml", "kubectl apply -f prod.yaml"}, + {"docker run nginx", "docker run nginx"}, + {"curl https://example.com/install.sh", "curl https://example.com/install.sh"}, + {"chmod 600 /etc/shadow", "chmod 600 /etc/shadow"}, + {"npm install lodash", "npm install *"}, + // Strip pipes and redirection first + {"sudo apt-get install nmap --dry-run 2>&1 | head -1", "sudo apt-get install nmap *"}, + {"cat /etc/passwd > /tmp/out", "cat /etc/passwd *"}, // 2 tokens after strip, path-like → append * + {"ls -la >> log.txt", "ls -la"}, // 2 tokens after strip, no dot/slash → as-is + {"some-cmd 2> /dev/null", "some-cmd"}, // 1 token after strip → as-is + // git commit -m "message" — quoted args become multiple fields + {"git commit -m fix-bug", "git commit -m *"}, + // Short commands (1-2 tokens) + {"ls", "ls"}, + {"whoami", "whoami"}, + {"cat /etc/hosts", "cat /etc/hosts *"}, // path-like → append * + {"python3 script.py", "python3 script.py *"}, // dot → append * + {"echo hello", "echo hello"}, // no dot/slash → keep as-is + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := policy.BuildAllowPattern(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/mcp/proxy.go b/internal/mcp/proxy.go index 2233b765..8cf723b8 100644 --- a/internal/mcp/proxy.go +++ b/internal/mcp/proxy.go @@ -87,6 +87,26 @@ func WithApprovalStore(store *approval.Store) Option { } } +// WithAgentID sets the agent identity used in policy evaluation and audit events. +// Defaults to "mcp-client" if not set. +func WithAgentID(id string) Option { + return func(p *Proxy) { + if strings.TrimSpace(id) != "" { + p.agentID = strings.TrimSpace(id) + } + } +} + +// WithSessionID sets the session identity used in policy evaluation and audit events. +// Defaults to "mcp-proxy" if not set. +func WithSessionID(id string) Option { + return func(p *Proxy) { + if strings.TrimSpace(id) != "" { + p.sessionID = strings.TrimSpace(id) + } + } +} + // Proxy evaluates MCP tools/call requests before forwarding to child MCP server. type Proxy struct { engine *engine.Engine @@ -97,6 +117,8 @@ type Proxy struct { filterTools bool toolMapping map[string]string approvals *approval.Store + agentID string + sessionID string childIn io.WriteCloser childOut io.Reader @@ -139,6 +161,12 @@ func NewProxy(eng *engine.Engine, sink audit.AuditSink, childIn io.WriteCloser, if p.mode == "" { p.mode = defaultMode } + if p.agentID == "" { + p.agentID = "mcp-client" + } + if p.sessionID == "" { + p.sessionID = "mcp-proxy" + } return p } @@ -314,8 +342,8 @@ func (p *Proxy) handleToolsCall(ctx context.Context, req Request, rawLine []byte call := engine.ToolCall{ ID: audit.NewEventID(), - Agent: "mcp-client", - Session: "mcp-proxy", + Agent: p.agentID, + Session: p.sessionID, Tool: mappedTool, Params: requestData, Input: params.Arguments, @@ -637,8 +665,8 @@ func (p *Proxy) maybeFilterToolsList(resp Response) ([]byte, bool, error) { } call := engine.ToolCall{ ID: audit.NewEventID(), - Agent: "mcp-client", - Session: "mcp-proxy", + Agent: p.agentID, + Session: p.sessionID, Tool: toolType, Params: requestData, Timestamp: time.Now().UTC(), diff --git a/internal/policy/glob.go b/internal/policy/glob.go new file mode 100644 index 00000000..b29b9d9d --- /dev/null +++ b/internal/policy/glob.go @@ -0,0 +1,196 @@ +// Copyright 2026 The Rampart Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "fmt" + "net/url" + "path" + "strings" +) + +// BuildAllowPattern converts a literal command string into a smart glob pattern +// suitable for command_matches. It strips output redirection/pipes and replaces +// trailing arguments with wildcards so that similar commands (e.g. different +// package names) are covered by a single rule. +func BuildAllowPattern(cmd string) string { + // Step 1: Strip output redirection and pipes. + // Remove everything after the first |, 2>&1, >, >>, or 2>. + clean := cmd + for _, sep := range []string{" 2>&1", " |", " >>", " 2>", " >"} { + if idx := strings.Index(clean, sep); idx != -1 { + clean = clean[:idx] + } + } + clean = strings.TrimSpace(clean) + if clean == "" { + return cmd + } + + // Step 2: Tokenize. + tokens := strings.Fields(clean) + if shouldKeepExact(clean, tokens) { + return clean + } + + // Step 3: For 3+ tokens, replace trailing argument(s) with *. + if len(tokens) >= 3 { + // Keep all tokens except the last, append * + return strings.Join(tokens[:len(tokens)-1], " ") + " *" + } + + // Step 4: For 1-2 tokens, keep as-is. + // Append * if the last token looks like a filename (contains a dot or slash). + if len(tokens) > 0 { + last := tokens[len(tokens)-1] + if strings.Contains(last, ".") || strings.Contains(last, "/") { + return strings.Join(tokens, " ") + " *" + } + } + + return clean +} + +// wrapperPrefixes are tokens that can precede a command without changing its semantics, +// e.g. "sudo docker run" should still match the "docker run" dangerous prefix. +var wrapperPrefixes = map[string]bool{ + "sudo": true, "env": true, "nice": true, "nohup": true, + "time": true, "strace": true, "ltrace": true, +} + +func shouldKeepExact(clean string, tokens []string) bool { + if len(tokens) == 0 { + return false + } + + // Strip leading wrapper prefixes so "sudo docker run" matches "docker run" + effective := tokens + for len(effective) > 0 && wrapperPrefixes[effective[0]] { + effective = effective[1:] + } + + if hasDangerousPrefix(effective, []string{"docker", "run"}) || + hasDangerousPrefix(effective, []string{"docker", "exec"}) || + hasDangerousPrefix(effective, []string{"kubectl", "apply"}) || + hasDangerousPrefix(effective, []string{"kubectl", "exec"}) || + hasDangerousPrefix(effective, []string{"kubectl", "delete"}) || + hasDangerousPrefix(effective, []string{"rm"}) || + hasDangerousPrefix(effective, []string{"dd"}) || + hasDangerousPrefix(effective, []string{"mkfs"}) { + return true + } + + if isExternalDownload(effective) { + return true + } + + if isSensitiveOwnershipChange(effective) { + return true + } + + return false +} + +func hasDangerousPrefix(tokens, prefix []string) bool { + if len(tokens) < len(prefix) { + return false + } + for i := range prefix { + if tokens[i] != prefix[i] { + return false + } + } + return true +} + +func isExternalDownload(tokens []string) bool { + if len(tokens) < 2 { + return false + } + if tokens[0] != "curl" && tokens[0] != "wget" { + return false + } + + for _, token := range tokens[1:] { + if !strings.Contains(token, "://") { + continue + } + u, err := url.Parse(token) + if err != nil { + continue + } + host := strings.ToLower(u.Hostname()) + if host == "" || host == "localhost" || host == "127.0.0.1" || host == "::1" { + continue + } + return true + } + return false +} + +func isSensitiveOwnershipChange(tokens []string) bool { + if len(tokens) < 2 { + return false + } + if tokens[0] != "chmod" && tokens[0] != "chown" { + return false + } + + for _, token := range tokens[1:] { + if strings.HasPrefix(token, "-") { + continue + } + if isSensitivePathToken(token) { + return true + } + } + return false +} + +func isSensitivePathToken(token string) bool { + cleaned := strings.Trim(token, `"'`) + if cleaned == "" || !strings.HasPrefix(cleaned, "/") { + return false + } + + // Normalize using forward slashes only (filepath.Clean uses backslashes on Windows) + // path.Clean resolves .., ., and double slashes safely on all platforms. + normalized := path.Clean(strings.ReplaceAll(cleaned, "\\", "/")) + sensitiveRoots := []string{ + "/", + "/boot", + "/dev", + "/etc", + "/root", + "/sys", + "/usr", + "/var", + } + for _, root := range sensitiveRoots { + if normalized == root || strings.HasPrefix(normalized, root+"/") { + return true + } + } + return false +} + +// HashPattern returns a hex string derived from a djb2 hash of the pattern, +// suitable for generating stable rule names like "user-allow-{hash}". +func HashPattern(s string) string { + var hash uint32 = 5381 + for _, b := range []byte(s) { + hash = hash*33 + uint32(b) + } + return fmt.Sprintf("%08x", hash) +} diff --git a/internal/policy/glob_test.go b/internal/policy/glob_test.go new file mode 100644 index 00000000..b4b403b5 --- /dev/null +++ b/internal/policy/glob_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 The Rampart Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildAllowPattern(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"sudo apt-get install nmap", "sudo apt-get install *"}, + {"kubectl apply -f prod.yaml", "kubectl apply -f prod.yaml"}, + {"kubectl delete pod api-0", "kubectl delete pod api-0"}, + {"docker run nginx", "docker run nginx"}, + {"docker exec app /bin/sh", "docker exec app /bin/sh"}, + {"sudo docker run nginx", "sudo docker run nginx"}, + {"sudo kubectl apply -f prod.yaml", "sudo kubectl apply -f prod.yaml"}, + {"chmod 777 /tmp/../etc/shadow", "chmod 777 /tmp/../etc/shadow"}, + {"chown root /./etc/passwd", "chown root /./etc/passwd"}, + {"npm install lodash", "npm install *"}, + {"curl https://example.com/install.sh", "curl https://example.com/install.sh"}, + {"wget https://example.com/tool.tgz", "wget https://example.com/tool.tgz"}, + {"curl http://localhost:8080/health", "curl http://localhost:8080/health *"}, + {"chmod 600 /etc/shadow", "chmod 600 /etc/shadow"}, + {"chown root:root /var/lib/data", "chown root:root /var/lib/data"}, + {"sudo rm -rf /tmp/build", "sudo rm -rf /tmp/build"}, + {"sudo apt-get install nmap --dry-run 2>&1 | head -1", "sudo apt-get install nmap *"}, + {"cat /etc/passwd > /tmp/out", "cat /etc/passwd *"}, + {"ls -la >> log.txt", "ls -la"}, + {"some-cmd 2> /dev/null", "some-cmd"}, + {"git commit -m fix-bug", "git commit -m *"}, + {"ls", "ls"}, + {"whoami", "whoami"}, + {"cat /etc/hosts", "cat /etc/hosts *"}, + {"python3 script.py", "python3 script.py *"}, + {"echo hello", "echo hello"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := BuildAllowPattern(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestHashPattern_Deterministic(t *testing.T) { + h1 := HashPattern("sudo apt-get install *") + h2 := HashPattern("sudo apt-get install *") + assert.Equal(t, h1, h2) + assert.Len(t, h1, 8) // 8 hex chars = 32-bit hash +} + +func TestHashPattern_Different(t *testing.T) { + h1 := HashPattern("sudo apt-get install *") + h2 := HashPattern("docker run *") + assert.NotEqual(t, h1, h2) +} diff --git a/internal/proxy/integration_test.go b/internal/proxy/integration_test.go index b8a9c5b9..f54af305 100644 --- a/internal/proxy/integration_test.go +++ b/internal/proxy/integration_test.go @@ -99,11 +99,11 @@ func TestEndToEnd_StandardProfile(t *testing.T) { wantAction: "deny", }, { - name: "read: .env → denied", + name: "read: .env → watched", tool: "read", params: map[string]any{"path": "/app/.env"}, - wantStatus: http.StatusForbidden, - wantAction: "deny", + wantStatus: http.StatusOK, + wantAction: "watch", }, { name: "read: main.go → allowed", diff --git a/internal/proxy/learn_handlers.go b/internal/proxy/learn_handlers.go new file mode 100644 index 00000000..9e4136f2 --- /dev/null +++ b/internal/proxy/learn_handlers.go @@ -0,0 +1,236 @@ +// Copyright 2026 The Rampart Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/peg/rampart/internal/policy" + "gopkg.in/yaml.v3" +) + +// learnRequest is the request body for POST /v1/rules/learn. +type learnRequest struct { + Tool string `json:"tool"` + Args string `json:"args"` + Decision string `json:"decision"` + Source string `json:"source"` + Agent string `json:"agent"` + Session string `json:"session"` +} + +// learnResponse is the response body for POST /v1/rules/learn. +type learnResponse struct { + RuleName string `json:"rule_name"` + Tool string `json:"tool"` + Pattern string `json:"pattern"` + Decision string `json:"decision"` + Source string `json:"source"` +} + +// userOverridesPolicy is the YAML structure for user-overrides.yaml. +type userOverridesPolicy struct { + Policies []userOverrideEntry `yaml:"policies"` +} + +type userOverrideEntry struct { + Name string `yaml:"name"` + Match userOverrideMatch `yaml:"match"` + Rules []userOverrideRule `yaml:"rules"` +} + +// toolList unmarshals both scalar ("exec") and sequence (["exec"]) YAML forms. +type toolList []string + +func (t *toolList) UnmarshalYAML(unmarshal func(interface{}) error) error { + // Try sequence first + var seq []string + if err := unmarshal(&seq); err == nil { + *t = seq + return nil + } + // Fall back to scalar string + var s string + if err := unmarshal(&s); err != nil { + return err + } + *t = []string{s} + return nil +} + +type userOverrideMatch struct { + Tool toolList `yaml:"tool"` +} + +type userOverrideRule struct { + When userOverrideWhen `yaml:"when"` + Action string `yaml:"action"` + Message string `yaml:"message"` +} + +type userOverrideWhen struct { + CommandMatches []string `yaml:"command_matches,omitempty,flow"` +} + +func (s *Server) handleLearnRule(w http.ResponseWriter, r *http.Request) { + if !s.checkAdminAuth(w, r) { + return + } + + var req learnRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + if req.Tool == "" || req.Args == "" { + writeError(w, http.StatusBadRequest, "tool and args are required") + return + } + if req.Decision != "allow" && req.Decision != "deny" { + writeError(w, http.StatusBadRequest, "decision must be \"allow\" or \"deny\"") + return + } + + // Compute smart glob pattern. + pattern := policy.BuildAllowPattern(req.Args) + hash := policy.HashPattern(pattern) + ruleName := fmt.Sprintf("user-allow-%s", hash) + + // Resolve overrides path. + home, err := os.UserHomeDir() + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve home directory") + return + } + overridesPath := filepath.Join(home, ".rampart", "policies", "user-overrides.yaml") + + // Ensure directory exists. + if err := os.MkdirAll(filepath.Dir(overridesPath), 0o750); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to create policies dir: %v", err)) + return + } + + s.policyWriteMu.Lock() + defer s.policyWriteMu.Unlock() + + // Read or initialize the file. + var cfg userOverridesPolicy + data, err := os.ReadFile(overridesPath) + if err == nil { + if err := yaml.Unmarshal(data, &cfg); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to parse user-overrides.yaml: %v", err)) + return + } + } + // If file doesn't exist, cfg.Policies will be nil — that's fine. + + // Check for duplicate pattern (by rule name). + for _, p := range cfg.Policies { + if p.Name == ruleName { + // 409 — return existing rule. + writeJSON(w, http.StatusConflict, learnResponse{ + RuleName: ruleName, + Tool: req.Tool, + Pattern: pattern, + Decision: req.Decision, + Source: req.Source, + }) + return + } + } + + // Build new entry. + entry := userOverrideEntry{ + Name: ruleName, + Match: userOverrideMatch{ + Tool: []string{req.Tool}, + }, + Rules: []userOverrideRule{ + { + When: userOverrideWhen{ + CommandMatches: []string{pattern}, + }, + Action: req.Decision, + Message: fmt.Sprintf("User %s (always) via %s", req.Decision, req.Source), + }, + }, + } + cfg.Policies = append(cfg.Policies, entry) + + // Marshal and write atomically. + out, err := yaml.Marshal(&cfg) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to marshal policy: %v", err)) + return + } + + header := "# Rampart user override policies\n# Auto-generated entries are added here when you click \"Always Allow\"\n# This file is never overwritten by upgrades or rampart setup\n" + content := header + string(out) + + dir := filepath.Dir(overridesPath) + tmpFile, err := os.CreateTemp(dir, ".rampart-user-overrides-*.yaml.tmp") + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to create temp file: %v", err)) + return + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.WriteString(content); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write: %v", err)) + return + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to close temp file: %v", err)) + return + } + if err := os.Rename(tmpPath, overridesPath); err != nil { + os.Remove(tmpPath) + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to rename: %v", err)) + return + } + + // Reload policies so the new rule takes effect immediately. + if s.engine != nil { + if reloadErr := s.engine.Reload(); reloadErr != nil { + s.logger.Warn("proxy: learn rule written but reload failed", "error", reloadErr) + } + } + + s.logger.Info("proxy: learned rule", "rule", ruleName, "tool", req.Tool, "pattern", pattern, "decision", req.Decision) + + // Broadcast SSE event. + s.broadcastSSE(map[string]any{ + "type": "rule.learned", + "rule_name": ruleName, + "tool": req.Tool, + "pattern": pattern, + "decision": req.Decision, + }) + + writeJSON(w, http.StatusCreated, learnResponse{ + RuleName: ruleName, + Tool: req.Tool, + Pattern: pattern, + Decision: req.Decision, + Source: req.Source, + }) +} diff --git a/internal/proxy/learn_handlers_test.go b/internal/proxy/learn_handlers_test.go new file mode 100644 index 00000000..13d98dad --- /dev/null +++ b/internal/proxy/learn_handlers_test.go @@ -0,0 +1,165 @@ +// Copyright 2026 The Rampart Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/peg/rampart/internal/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupLearnTestServer(t *testing.T) (*httptest.Server, string, string) { + t.Helper() + + srv, token, _ := setupTestServer(t, testPolicyYAML, "enforce") + + // Override HOME (Linux/macOS) and USERPROFILE (Windows) so user-overrides.yaml goes to a temp dir. + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + + ts := httptest.NewServer(srv.handler()) + t.Cleanup(ts.Close) + + return ts, token, tmpHome +} + +func postLearn(t *testing.T, ts *httptest.Server, token, body string) *http.Response { + t.Helper() + + req, err := http.NewRequest(http.MethodPost, ts.URL+"/v1/rules/learn", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + return resp +} + +func TestLearnRule_CreatesRule(t *testing.T) { + ts, token, tmpHome := setupLearnTestServer(t) + + body := `{"tool":"exec","args":"sudo apt-get install nmap","decision":"allow","source":"openclaw-approval","agent":"main","session":"abc123"}` + resp := postLearn(t, ts, token, body) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var result learnResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + + assert.Equal(t, "sudo apt-get install *", result.Pattern) + assert.Equal(t, "exec", result.Tool) + assert.Equal(t, "allow", result.Decision) + assert.Equal(t, "openclaw-approval", result.Source) + assert.Contains(t, result.RuleName, "user-allow-") + + // Verify file was written. + overridesPath := filepath.Join(tmpHome, ".rampart", "policies", "user-overrides.yaml") + data, err := os.ReadFile(overridesPath) + require.NoError(t, err) + assert.Contains(t, string(data), result.RuleName) + assert.Contains(t, string(data), "sudo apt-get install *") +} + +func TestLearnRule_Duplicate409(t *testing.T) { + ts, token, _ := setupLearnTestServer(t) + + body := `{"tool":"exec","args":"sudo apt-get install nmap","decision":"allow","source":"openclaw-approval"}` + + // First call — 201. + resp1 := postLearn(t, ts, token, body) + assert.Equal(t, http.StatusCreated, resp1.StatusCode) + + // Second call with same args — 409. + resp2 := postLearn(t, ts, token, body) + assert.Equal(t, http.StatusConflict, resp2.StatusCode) + + var result learnResponse + require.NoError(t, json.NewDecoder(resp2.Body).Decode(&result)) + assert.Equal(t, "sudo apt-get install *", result.Pattern) +} + +func TestLearnRule_MissingFields(t *testing.T) { + ts, token, _ := setupLearnTestServer(t) + + tests := []struct { + name string + body string + }{ + {"missing tool", `{"args":"ls","decision":"allow"}`}, + {"missing args", `{"tool":"exec","decision":"allow"}`}, + {"missing both", `{"decision":"allow"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := postLearn(t, ts, token, tt.body) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + } +} + +func TestLearnRule_InvalidDecision(t *testing.T) { + ts, token, _ := setupLearnTestServer(t) + + body := `{"tool":"exec","args":"ls","decision":"maybe"}` + resp := postLearn(t, ts, token, body) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestLearnRule_CorrectGlob(t *testing.T) { + tests := []struct { + args string + pattern string + }{ + {"sudo apt-get install nmap", "sudo apt-get install *"}, + {"sudo apt-get install nmap --dry-run 2>&1 | head -1", "sudo apt-get install nmap *"}, + {"docker run nginx", "docker run nginx"}, + {"curl https://example.com/install.sh", "curl https://example.com/install.sh"}, + {"ls", "ls"}, + {"cat /etc/hosts", "cat /etc/hosts *"}, + } + + for _, tt := range tests { + t.Run(tt.args, func(t *testing.T) { + got := policy.BuildAllowPattern(tt.args) + assert.Equal(t, tt.pattern, got) + }) + } +} + +func TestLearnRule_NoAuth(t *testing.T) { + ts, _, _ := setupLearnTestServer(t) + + body := `{"tool":"exec","args":"ls","decision":"allow"}` + req, err := http.NewRequest(http.MethodPost, ts.URL+"/v1/rules/learn", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + // No Authorization header. + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} diff --git a/internal/proxy/rules_handlers.go b/internal/proxy/rules_handlers.go index 4603f975..01fb505d 100644 --- a/internal/proxy/rules_handlers.go +++ b/internal/proxy/rules_handlers.go @@ -18,7 +18,6 @@ import ( "net/http" "os" "path/filepath" - "strconv" "time" "github.com/peg/rampart/internal/engine" @@ -98,14 +97,17 @@ func (s *Server) handleDeleteAutoAllowed(w http.ResponseWriter, r *http.Request) return } - indexStr := r.PathValue("index") - index, err := strconv.Atoi(indexStr) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid index") + name := r.PathValue("name") + if name == "" { + writeError(w, http.StatusBadRequest, "rule name is required") return } policyPath := engine.DefaultAutoAllowedPath() + + s.policyWriteMu.Lock() + defer s.policyWriteMu.Unlock() + data, err := os.ReadFile(policyPath) if err != nil { if os.IsNotExist(err) { @@ -122,13 +124,21 @@ func (s *Server) handleDeleteAutoAllowed(w http.ResponseWriter, r *http.Request) return } - if index < 0 || index >= len(cfg.Policies) { - writeError(w, http.StatusNotFound, "rule index out of range") + // Find the policy with matching name. + found := -1 + for i, p := range cfg.Policies { + if p.Name == name { + found = i + break + } + } + if found < 0 { + writeError(w, http.StatusNotFound, fmt.Sprintf("no rule with name %q", name)) return } - // Remove the rule at index. - cfg.Policies = append(cfg.Policies[:index], cfg.Policies[index+1:]...) + // Remove the rule by name. + cfg.Policies = append(cfg.Policies[:found], cfg.Policies[found+1:]...) // If no rules left, delete the file. if len(cfg.Policies) == 0 { diff --git a/internal/proxy/server.go b/internal/proxy/server.go index 2f4de9b9..dd4c911d 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -38,27 +38,29 @@ const statusCallCountWindow = time.Hour // Server is Rampart's HTTP proxy runtime for policy-aware tool calls. type Server struct { - engine *engine.Engine - sink audit.AuditSink - approvals *approval.Store - approvalTimeout time.Duration - token string - tokenStore *token.Store - mode string - configPath string - logger *slog.Logger - resolveBaseURL string - listenAddr string - signer *signing.Signer - mu sync.Mutex - server *http.Server - startedAt time.Time - notifyConfig *engine.NotifyConfig - metricsEnabled bool - auditDir string - sse *sseHub - lastReloadAPI time.Time // Rate limiting for /v1/policy/reload - stopCleanup chan struct{} + engine *engine.Engine + sink audit.AuditSink + approvals *approval.Store + approvalTimeout time.Duration + token string + tokenStore *token.Store + mode string + configPath string + logger *slog.Logger + resolveBaseURL string + listenAddr string + signer *signing.Signer + mu sync.Mutex + policyWriteMu sync.Mutex + server *http.Server + startedAt time.Time + notifyConfig *engine.NotifyConfig + metricsEnabled bool + auditDir string + sse *sseHub + lastReloadAPI time.Time // Rate limiting for /v1/policy/reload + stopCleanup chan struct{} + approvalPersistFile string } // Option configures a proxy server. @@ -129,6 +131,14 @@ func WithApprovalTimeout(d time.Duration) Option { } } +// WithApprovalPersistenceFile sets the path for the JSONL file used to persist +// pending approvals across server restarts. If empty, persistence is disabled. +func WithApprovalPersistenceFile(path string) Option { + return func(s *Server) { + s.approvalPersistFile = path + } +} + // WithConfigPath sets the config path string shown in the /v1/policy endpoint. // Use "embedded:standard" when the embedded default policy is active. func WithConfigPath(path string) Option { @@ -160,11 +170,15 @@ func New(eng *engine.Engine, sink audit.AuditSink, opts ...Option) *Server { s.mode = defaultMode } - // Initialize approval store with timeout. + // Initialize approval store with timeout and optional persistence. var storeOpts []approval.Option if s.approvalTimeout > 0 { storeOpts = append(storeOpts, approval.WithTimeout(s.approvalTimeout)) } + if s.approvalPersistFile != "" { + storeOpts = append(storeOpts, approval.WithPersistenceFile(s.approvalPersistFile)) + } + storeOpts = append(storeOpts, approval.WithLogger(s.logger)) storeOpts = append(storeOpts, approval.WithExpireCallback(func(req *approval.Request) { s.broadcastSSE(map[string]any{"type": "approvals"}) // Write audit event so expired approvals appear in History as denied. @@ -341,7 +355,8 @@ func (s *Server) handler() http.Handler { mux.HandleFunc("POST /v1/approvals/{id}/resolve", s.handleResolveApproval) mux.HandleFunc("POST /v1/approvals/bulk-resolve", s.handleBulkResolve) mux.HandleFunc("GET /v1/rules/auto-allowed", s.handleGetAutoAllowed) - mux.HandleFunc("DELETE /v1/rules/auto-allowed/{index}", s.handleDeleteAutoAllowed) + mux.HandleFunc("DELETE /v1/rules/auto-allowed/{name}", s.handleDeleteAutoAllowed) + mux.HandleFunc("POST /v1/rules/learn", s.handleLearnRule) mux.HandleFunc("GET /v1/audit/events", s.handleAuditEvents) mux.HandleFunc("GET /v1/audit/dates", s.handleAuditDates) mux.HandleFunc("GET /v1/audit/export", s.handleAuditExport) diff --git a/policies/openclaw.yaml b/policies/openclaw.yaml index 1d28661e..ff881b81 100644 --- a/policies/openclaw.yaml +++ b/policies/openclaw.yaml @@ -1,39 +1,29 @@ +# rampart-policy-version: 1.0.0 # Rampart built-in profile: openclaw # Use with: rampart init --profile openclaw # -# Session-aware policy for OpenClaw AI agent orchestrators. Applies the -# "seatbelt not a cage" philosophy: the main session (direct human chat) -# has full workspace access, while subagents and cron jobs face tighter -# restrictions on sensitive files and external actions. +# Policy profile for OpenClaw AI agent environments. +# Covers OpenClaw's full tool surface: read, write, edit, exec, +# web_fetch, web_search, browser, message, canvas. # -# Prerequisites: -# rampart setup openclaw --patch-tools -# -# Session context: -# The OpenClaw shell shim sends session identity via the RAMPART_SESSION -# or OPENCLAW_SESSION_KIND environment variable. Set these in your -# OpenClaw configuration to distinguish session types: -# main — direct chat with the human operator -# subagent:* — spawned sub-agent sessions -# cron:* — scheduled/periodic tasks +# Philosophy: deny by default, allow known-safe operations explicitly. +# Designed to be layered with standard.yaml for comprehensive protection. # -# Default action: allow (permissive baseline — rules restrict specific risks) +# Prerequisites: +# rampart setup openclaw --plugin (native hook interception, recommended) +# rampart setup openclaw --patch-tools (legacy dist patching) version: "1" -default_action: allow +default_action: deny policies: # ── Self-modification protection ────────────────────────────────────── - # No session can modify its own Rampart policy. Human-only. - - name: openclaw-block-self-modification - description: "Prevent any agent session from modifying Rampart policy" - priority: 1 + # Agents cannot modify their own Rampart policy or disable enforcement. + - name: block-self-modification + description: "Prevent AI agents from modifying their own Rampart policy" match: tool: ["exec"] rules: - # Allow read-only and safe operational commands. - # SECURITY: do NOT allow bare "rampart serve" — an agent could restart - # with --mode disabled or altered flags. Only allow explicit safe subcommands. - action: allow when: command_contains: @@ -46,11 +36,7 @@ policies: - "rampart log" - "rampart watch" - "rampart convert" - message: "Read-only/safe rampart commands are allowed" - # SECURITY: deny policy modification AND serve/upgrade abuse. - # "rampart serve" denied because agent could restart with --mode disabled. - # "rampart upgrade" denied because agent could replace the binary. - # Safe serve subcommands (stop, install, uninstall) allowed above. + message: "Read-only Rampart commands allowed" - action: deny when: command_contains: @@ -72,96 +58,178 @@ policies: - ">> ~/.rampart/" message: "Direct writes to .rampart/ directory blocked" - # ── Credential protection ───────────────────────────────────────────── - # Block reads of SSH keys, cloud credentials, and secrets — all sessions. - - name: openclaw-block-credentials - description: "Block access to SSH keys, cloud credentials, and secret files" - priority: 2 + # ── Safe workspace reads ─────────────────────────────────────────────── + # Allow reading workspace files and common project files. + - name: allow-workspace-reads + description: "Allow reading workspace and project files" match: - tool: ["read", "exec"] + tool: ["read"] rules: - - action: deny + - action: allow when: path_matches: + # Workspace files + - "**/.openclaw/workspace/**" + - "**/workspace/**" + # Common project files + - "**/*.go" + - "**/*.ts" + - "**/*.js" + - "**/*.py" + - "**/*.md" + - "**/*.yaml" + - "**/*.yml" + - "**/*.json" + - "**/*.toml" + - "**/*.txt" + - "**/*.sh" + - "**/*.rs" + - "**/*.html" + - "**/*.css" + - "**/*.sql" + - "**/*.env.example" + path_not_matches: + # Block credentials even within workspace - "**/.ssh/id_*" - - "**/.ssh/known_hosts" - "**/.aws/credentials" - "**/.kube/config" - "**/.docker/config.json" - "**/.git-credentials" - "**/.netrc" - message: "Credential file access blocked" - - action: deny - when: - command_matches: - - "cat */.ssh/id_*" - - "cat */.aws/credentials" - - "cat */.env" - message: "Credential read via exec blocked" - - # ── Response scanning ───────────────────────────────────────────────── - # Block credentials from leaking into the agent's context window. - - name: openclaw-response-scanning - description: "Block secrets in tool responses before they enter agent context" - priority: 3 - match: - tool: ["read", "exec"] - rules: - - action: deny - when: - response_matches: - - "(?i)(AWS_SECRET_ACCESS_KEY|AKIA[0-9A-Z]{16})" - - "(?i)(PRIVATE KEY-----)" - - "(?i)(ghp_[a-zA-Z0-9]{36})" - - "(?i)(sk-[a-zA-Z0-9]{20,})" - message: "Response blocked: contains credentials" + - "**/.env" + - "**/.env.*" + message: "Workspace file read allowed" - # ── Subagent restrictions ───────────────────────────────────────────── - # Subagents and cron jobs cannot modify identity/config files. - - name: openclaw-subagent-write-guard - description: "Restrict subagent and cron write access to sensitive files" - priority: 10 + # ── Memory and log file writes ───────────────────────────────────────── + # Allow writing to memory files, daily logs, and workspace outputs. + - name: allow-workspace-writes + description: "Allow writing memory files, logs, and workspace outputs" match: tool: ["write", "edit"] rules: - - action: deny + - action: allow when: - session_matches: ["subagent:*", "cron:*"] path_matches: + # Memory and daily notes + - "**/memory/**" + - "**/memory/*.md" + - "**/MEMORY.md" + - "**/HEARTBEAT.md" + # Workspace outputs + - "**/.openclaw/workspace/**" + - "**/workspace/**" + # Common project files (safe to write) + - "**/*.go" + - "**/*.ts" + - "**/*.js" + - "**/*.py" + - "**/*.md" + - "**/*.yaml" + - "**/*.yml" + - "**/*.json" + - "**/*.toml" + - "**/*.txt" + - "**/*.sh" + - "**/*.rs" + - "**/*.html" + - "**/*.css" + - "**/*.sql" + path_not_matches: + # Never write credentials or identity files via subagent + - "**/.ssh/**" + - "**/.aws/**" + - "**/.gnupg/**" + - "**/.rampart/**" - "**/SOUL.md" - "**/IDENTITY.md" - - "**/USER.md" - - "**/AGENTS.md" - - "**/.openclaw/config*" - - "**/.openclaw/openclaw.json*" - message: "Subagents and cron jobs cannot modify identity or config files" - - # ── Exfiltration protection ─────────────────────────────────────────── - # Block data exfiltration via curl/wget POST to unknown endpoints. - - name: openclaw-block-exfil - description: "Block data exfiltration via HTTP POST to unknown hosts" - priority: 5 + - "**/.openclaw/openclaw.json" + message: "Workspace file write allowed" + + # ── Safe exec commands ───────────────────────────────────────────────── + # Allow common development and system commands. + - name: allow-safe-exec + description: "Allow common dev tools and shell operations" match: tool: ["exec"] rules: - - action: deny + - action: allow when: - command_contains: - - "curl -X POST" - - "curl --data " - - "curl -d @" - - "wget --post-data" - - "wget --post-file" + command_matches: + # Go toolchain + - "go build *" + - "go test *" + - "go run *" + - "go fmt *" + - "go vet *" + - "go mod *" + - "go get *" + - "go install *" + - "go generate *" + # Git operations + - "git *" + # Node/npm/yarn + - "node *" + - "npm *" + - "yarn *" + - "npx *" + - "pnpm *" + # Python + - "python *" + - "python3 *" + - "pip *" + - "pip3 *" + # Common CLI tools + - "ls *" + - "ls" + - "cat *" + - "echo *" + - "grep *" + - "rg *" + - "find *" + - "head *" + - "tail *" + - "wc *" + - "sort *" + - "uniq *" + - "cut *" + - "awk *" + - "sed *" + - "tr *" + - "diff *" + - "cp *" + - "mv *" + - "mkdir *" + - "touch *" + - "chmod *" + - "pwd" + - "date" + - "which *" + - "type *" + - "env" + - "printenv *" + - "bash *" + - "sh *" + - "curl *" + - "wget *" + - "jq *" + - "yq *" + - "make *" + - "docker *" + - "kubectl *" command_not_matches: - - "*localhost*" - - "*127.0.0.1*" - message: "HTTP POST to external host blocked — potential data exfiltration" - - # ── Destructive commands ────────────────────────────────────────────── - # Block obviously destructive operations. - - name: openclaw-block-destructive - description: "Block commands that could destroy the system" - priority: 1 + # Block destructive variants + - "rm -rf /*" + - "rm -rf ~/*" + - "rm -rf /" + - "rm -rf ~" + - "chmod -R 777 /" + - "dd **of=/dev/sd**" + - "dd **of=/dev/nvme**" + message: "Safe exec command allowed" + + # ── Block destructive exec ───────────────────────────────────────────── + - name: block-destructive-exec + description: "Block destructive filesystem and system commands" match: tool: ["exec"] rules: @@ -174,43 +242,205 @@ policies: - "rm -rf ~" - "mkfs*" - "> /dev/sda" - - "dd if=/dev/zero of=/dev/*" + - "dd **of=/dev/**" - ":(){ :|:& };:" message: "Destructive command blocked" - # ── Network-aware logging ───────────────────────────────────────────── - # Log network access for audit trail (allow but record). - - name: openclaw-log-network - description: "Log outbound network commands for audit visibility" - priority: 50 + # ── Block credential access ──────────────────────────────────────────── + - name: block-credential-reads + description: "Block access to SSH keys, cloud credentials, and secret files" match: - tool: ["exec"] + tool: ["read"] + rules: + - action: deny + when: + path_matches: + - "**/.ssh/id_*" + - "**/.aws/credentials" + - "**/.aws/config" + - "**/.kube/config" + - "**/.docker/config.json" + - "**/.git-credentials" + - "**/.netrc" + - "**/.gnupg/**" + - "**/.env" + - "**/.env.*" + - "**/etc/shadow" + - "**/etc/passwd" + path_not_matches: + - "**/.ssh/*.pub" + - "**/.env.example" + - "**/.env.sample" + - "**/.env.template" + message: "Credential file access blocked" + + # ── web_fetch: allow known-safe domains ─────────────────────────────── + # Allow fetches to common dev/research domains; block exfil destinations. + - name: allow-safe-web-fetch + description: "Allow web_fetch to known-safe domains" + match: + tool: ["fetch", "web_fetch"] + rules: + - action: allow + when: + domain_matches: + # Package registries and docs + - "pkg.go.dev" + - "docs.rs" + - "crates.io" + - "npmjs.com" + - "*.npmjs.com" + - "pypi.org" + # Developer resources + - "github.com" + - "*.github.com" + - "raw.githubusercontent.com" + - "api.github.com" + - "docs.github.com" + - "stackoverflow.com" + - "*.stackoverflow.com" + # Search and reference + - "duckduckgo.com" + - "google.com" + - "wikipedia.org" + - "*.wikipedia.org" + - "developer.mozilla.org" + # OpenClaw and Rampart related + - "rampart.sh" + - "*.rampart.sh" + # LLM APIs (agents may need these) + - "api.anthropic.com" + - "api.openai.com" + message: "Web fetch to known-safe domain allowed" + - action: deny + when: + domain_matches: + # Known exfiltration services + - "*.ngrok.io" + - "ngrok.io" + - "*.ngrok-free.app" + - "*.requestbin.com" + - "*.hookbin.com" + - "*.webhook.site" + - "webhook.site" + - "*.pipedream.net" + - "*.beeceptor.com" + message: "Potential exfiltration domain blocked" + + # ── web_search: always allow ─────────────────────────────────────────── + - name: allow-web-search + description: "Allow web_search tool (read-only, no exfiltration risk)" + match: + tool: ["web_search"] + rules: + - action: allow + when: + default: true + message: "Web search allowed" + + # ── browser: allow known-safe + watch unknown domains ───────────────── + - name: allow-safe-browser + description: "Allow browser navigation to known-safe domains" + match: + tool: ["browser"] + rules: + - action: allow + when: + domain_matches: + # Discord / Telegram for OpenClaw message plugin UIs + - "discord.com" + - "*.discord.com" + - "telegram.org" + - "*.telegram.org" + - "web.telegram.org" + # Developer portals + - "github.com" + - "*.github.com" + - "localhost" + - "127.0.0.1" + - "*.local" + message: "Browser navigation to known-safe domain allowed" + + # ── watch-browser-navigation: audit unknown domain visits ───────────── + # Log browser tool calls to unknown/unclassified domains for audit visibility. + - name: watch-browser-navigation + description: "Audit browser navigation to unknown domains" + match: + tool: ["browser"] rules: - action: watch when: - command_matches: - - "curl *" - - "wget *" - - "ssh *" - - "scp *" - message: "Network access logged" - - # ── Production safety gates ─────────────────────────────────────────── - # Require human approval for production-impacting commands. - - name: openclaw-prod-approval - description: "Require human approval for production deployments" - priority: 20 + default: true + message: "Browser navigation to unclassified domain logged for audit" + + # ── message: allow send to known channels ───────────────────────────── + # Allow message sends to configured channels; log and review unknown targets. + - name: allow-safe-message + description: "Allow message tool for common channels (Discord, Telegram)" match: - tool: ["exec"] + tool: ["message"] rules: - - action: require_approval + - action: allow when: - command_matches: - - "kubectl apply *" - - "kubectl delete *" - - "terraform apply *" - - "terraform destroy *" - - "docker push *" - - "helm install *" - - "helm upgrade *" - message: "Production command requires human approval" + default: true + message: "Message tool action allowed" + + # ── canvas: allow all canvas operations ─────────────────────────────── + # Canvas is presentation/UI only — low risk. + - name: allow-canvas + description: "Allow canvas operations (presentation/UI, low risk)" + match: + tool: ["canvas"] + rules: + - action: allow + when: + default: true + message: "Canvas operation allowed" + + # ── Subagent write guard ─────────────────────────────────────────────── + # Subagents cannot modify identity/config files. + - name: openclaw-subagent-write-guard + description: "Restrict subagent write access to identity and config files" + priority: 10 + match: + tool: ["write", "edit"] + rules: + - action: deny + when: + session_matches: ["subagent:*", "cron:*"] + path_matches: + - "**/SOUL.md" + - "**/IDENTITY.md" + - "**/USER.md" + - "**/AGENTS.md" + - "**/.openclaw/openclaw.json" + message: "Subagents cannot modify identity or config files" + + # ── Response scanning ────────────────────────────────────────────────── + # Block credentials from leaking into agent context. + - name: block-credential-in-response + description: "Block secrets in tool responses before they enter agent context" + match: + tool: ["read", "exec", "web_fetch"] + rules: + - action: deny + when: + response_matches: + - "AKIA[0-9A-Z]{16}" + - "ASIA[0-9A-Z]{16}" + - "-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----" + - "ghp_[a-zA-Z0-9]{36}" + - "github_pat_[a-zA-Z0-9_]{80,}" + - "sk-[a-zA-Z0-9]{20,}" + message: "Response blocked: contains credentials" + + # ── Allow-unmatched catch-all (lowest priority) ──────────────────────── + # With default_action: deny, this is unreachable for non-matched rules. + # Kept for documentation clarity. Do not remove. + - name: allow-unmatched + priority: 10000 + rules: + - action: deny + when: + default: true + message: "No matching OpenClaw policy rule — blocked by default" diff --git a/policies/standard.yaml b/policies/standard.yaml index 1fdde941..100de162 100644 --- a/policies/standard.yaml +++ b/policies/standard.yaml @@ -334,9 +334,9 @@ policies: message: "Cloud upload requires approval" - name: watch-env-access - description: "Log access to environment variables — may contain API keys or secrets" + description: "Log access to environment variables and .env files — may contain API keys or secrets" match: - tool: ["exec"] + tool: ["exec", "read", "write"] rules: - action: watch when: @@ -346,6 +346,19 @@ policies: - "printenv" - "printenv *" message: "Environment variable dump logged" + - action: watch + when: + path_matches: + - "**/.env" + - "**/.env.*" + - "**/.envrc" + path_not_matches: + - "**/.env.example" + - "**/.env.example.*" + - "**/*.env.example" + - "**/.env.sample" + - "**/.env.template" + message: ".env file access logged — may contain secrets" - name: block-env-var-injection description: "Block environment variable injection with no legitimate agent use — preloaders, startup hooks, shell init files" @@ -461,9 +474,6 @@ policies: - "**/.kube/config" - "**/.docker/config.json" # Token / secrets files - - "**/.env" - - "**/.env.*" - - "**/.envrc" - "**/.netrc" - "**/.rampart/token" - "**/.rampart/tokens.json" @@ -481,13 +491,6 @@ policies: - "**/.secrets/**" - "**/.keys/**" - "**/.pki/**" - path_not_matches: - # Allow example/template env files (contain placeholder values, not real secrets) - - "**/.env.example" - - "**/.env.example.*" - - "**/*.env.example" - - "**/.env.sample" - - "**/.env.template" message: "Credential file access blocked" # System authentication / privilege files - action: deny diff --git a/policies/standard_test.go b/policies/standard_test.go index 6684eb25..011ae8d8 100644 --- a/policies/standard_test.go +++ b/policies/standard_test.go @@ -48,8 +48,8 @@ func TestStandardPolicyDecisions(t *testing.T) { {name: "deny read ssh key windows unc", tool: "read", path: "\\\\server\\share\\.ssh\\id_rsa", expected: engine.ActionDeny}, {name: "deny read aws credentials", tool: "read", path: "~/.aws/credentials", expected: engine.ActionDeny}, {name: "deny read aws credentials windows", tool: "read", path: "C:\\Users\\Trevor\\.aws\\credentials", expected: engine.ActionDeny}, - {name: "deny read dot env", tool: "read", path: "~/.env", expected: engine.ActionDeny}, - {name: "deny read dot env windows", tool: "read", path: "C:\\Users\\Trevor\\project\\.env", expected: engine.ActionDeny}, + {name: "deny read dot env", tool: "read", path: "~/.env", expected: engine.ActionWatch}, + {name: "deny read dot env windows", tool: "read", path: "C:\\Users\\Trevor\\project\\.env", expected: engine.ActionWatch}, {name: "deny read azure token windows", tool: "read", path: "C:\\Users\\Trevor\\.azure\\accessTokens.json", expected: engine.ActionDeny}, {name: "deny read kube config windows", tool: "read", path: "C:\\Users\\Trevor\\.kube\\config", expected: engine.ActionDeny}, {name: "deny read chrome data windows", tool: "read", path: "C:\\Users\\Trevor\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\Login Data", expected: engine.ActionDeny}, diff --git a/tests/e2e.yaml b/tests/e2e.yaml index c6d85bd7..8f051bdc 100644 --- a/tests/e2e.yaml +++ b/tests/e2e.yaml @@ -94,29 +94,29 @@ tests: params: {command: "rm -rf /tmp/build"} expect: allow - # ── Privilege escalation (require_approval) ───────────────────────────────── + # ── Privilege escalation (ask) ───────────────────────────────── - - name: "require_approval: sudo apt update" + - name: "ask: sudo apt update" tool: exec params: {command: "sudo apt update"} - expect: require_approval + expect: ask - - name: "require_approval: sudo systemctl restart nginx" + - name: "ask: sudo systemctl restart nginx" tool: exec params: {command: "sudo systemctl restart nginx"} - expect: require_approval + expect: ask - - name: "require_approval: sudo rm /etc/hosts" + - name: "ask: sudo rm /etc/hosts" tool: exec params: {command: "sudo rm /etc/hosts"} - expect: require_approval + expect: ask - # ── Destructive (require_approval for shred) ──────────────────────────────── + # ── Destructive (ask for shred) ──────────────────────────────── - - name: "require_approval: shred secrets" + - name: "ask: shred secrets" tool: exec params: {command: "shred -u secrets.txt"} - expect: require_approval + expect: ask # ── Credential access (must deny) ───────────────────────────────────────────