From 8d38fb525c7f6a47468ca759b8f6068191b12437 Mon Sep 17 00:00:00 2001 From: "clap [bot]" Date: Wed, 27 May 2026 17:42:55 +0000 Subject: [PATCH 1/9] feat: add hosted approval API foundation --- internal/audit/event.go | 10 +++ internal/proxy/approval_handlers.go | 122 +++++++++++++++++++++++++++ internal/proxy/eval_handlers.go | 73 ++++++++++++---- internal/proxy/server.go | 94 +++++++++++++++++++++ internal/proxy/server_test.go | 125 ++++++++++++++++++++++++++++ 5 files changed, 410 insertions(+), 14 deletions(-) diff --git a/internal/audit/event.go b/internal/audit/event.go index 760401c1..bd4691ca 100644 --- a/internal/audit/event.go +++ b/internal/audit/event.go @@ -62,6 +62,16 @@ type Event struct { // or CLAUDE_CONVERSATION_ID fallback. Empty if no grouping applies. RunID string `json:"run_id,omitempty"` + // ToolCallID is an optional host-provided identifier for the exact tool call. + // Agent hosts that own approval/resume flows can use this to correlate the + // Rampart policy decision with their native approval object and resumed call. + ToolCallID string `json:"tool_call_id,omitempty"` + + // ApprovalOwner records which system owns the user-facing approval object. + // In hosted-approval flows this is intentionally not a Rampart pending + // approval; it is correlation metadata for audit and callbacks. + ApprovalOwner map[string]any `json:"approval_owner,omitempty"` + // Tool is the tool that was invoked (e.g., "exec", "read"). Tool string `json:"tool"` diff --git a/internal/proxy/approval_handlers.go b/internal/proxy/approval_handlers.go index 27dc159f..ec30345a 100644 --- a/internal/proxy/approval_handlers.go +++ b/internal/proxy/approval_handlers.go @@ -442,6 +442,128 @@ func (s *Server) handleBulkResolve(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleResolveHostedApproval(w http.ResponseWriter, r *http.Request) { + if !s.checkAdminAuth(w, r) { + return + } + + auditID := strings.TrimSpace(r.PathValue("auditID")) + if auditID == "" { + writeError(w, http.StatusBadRequest, "audit_id is required") + return + } + + var req hostedApprovalResolveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err)) + return + } + + outcome := strings.ToLower(strings.TrimSpace(req.Outcome)) + switch outcome { + case "approved", "denied", "timeout", "cancelled": + // valid + default: + writeError(w, http.StatusBadRequest, "outcome must be one of: approved, denied, timeout, cancelled") + return + } + + scope := strings.ToLower(strings.TrimSpace(req.Scope)) + if scope != "" { + switch scope { + case "once", "session", "always": + // valid + default: + writeError(w, http.StatusBadRequest, "scope must be one of: once, session, always") + return + } + } + + resolvedBy := strings.TrimSpace(req.ResolvedBy) + if resolvedBy == "" { + resolvedBy = "api" + } + + resolvedAt := time.Now().UTC() + if strings.TrimSpace(req.ResolvedAt) != "" { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ResolvedAt)) + if err != nil { + writeError(w, http.StatusBadRequest, "resolved_at must be RFC3339") + return + } + resolvedAt = parsed.UTC() + } + + if s.sink == nil { + writeError(w, http.StatusServiceUnavailable, "audit sink is not initialized") + return + } + + tool := strings.TrimSpace(req.Tool) + if tool == "" { + tool = "hosted_approval" + } + + request := map[string]any{ + "action": "hosted_approval_resolved", + "audit_id": auditID, + "outcome": outcome, + "resolved_by": resolvedBy, + "resolved_at": resolvedAt.Format(time.RFC3339), + } + if req.ToolCallID != "" { + request["tool_call_id"] = req.ToolCallID + } + if req.HostApprovalID != "" { + request["host_approval_id"] = req.HostApprovalID + } + if scope != "" { + request["scope"] = scope + } + if req.Message != "" { + request["message"] = req.Message + } + if owner := req.ApprovalOwner.toMap(); len(owner) > 0 { + request["approval_owner"] = owner + } + + message := req.Message + if message == "" { + message = fmt.Sprintf("hosted approval %s by %s", outcome, resolvedBy) + } + + eventID := audit.NewEventID() + event := audit.Event{ + ID: eventID, + Timestamp: resolvedAt, + Agent: req.Agent, + Session: req.Session, + RunID: req.RunID, + ToolCallID: req.ToolCallID, + ApprovalOwner: req.ApprovalOwner.toMap(), + Tool: tool, + Request: request, + Decision: audit.EventDecision{ + Action: outcome, + Message: message, + }, + } + if err := s.sink.Write(event); err != nil { + s.logger.Error("proxy: audit write for hosted approval resolution failed", "error", err) + writeError(w, http.StatusServiceUnavailable, "failed to write hosted approval audit event") + return + } + s.broadcastSSE(map[string]any{"type": "audit", "event": event}) + + writeJSON(w, http.StatusOK, map[string]any{ + "audit_id": auditID, + "event_id": eventID, + "status": "recorded", + "outcome": outcome, + "resolved_at": resolvedAt.Format(time.RFC3339), + }) +} + func (s *Server) approvalResolveURL(id string, expiresAt time.Time) string { base := s.resolveURLBase() if base == "" { diff --git a/internal/proxy/eval_handlers.go b/internal/proxy/eval_handlers.go index 7b7b9d55..1e74aff8 100644 --- a/internal/proxy/eval_handlers.go +++ b/internal/proxy/eval_handlers.go @@ -122,7 +122,7 @@ func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) { SetUptime(time.Since(s.startedAt)) } - s.writeAudit(req, toolName, decision) + auditID := s.writeAudit(req, toolName, decision) allowed := decision.Action == engine.ActionAllow || decision.Action == engine.ActionWatch resp := map[string]any{ @@ -131,6 +131,9 @@ func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) { "message": decision.Message, "eval_duration_us": decision.EvalDuration.Microseconds(), } + if auditID != "" { + resp["audit_id"] = auditID + } if len(decision.MatchedPolicies) > 0 { resp["policy"] = decision.MatchedPolicies[0] @@ -169,23 +172,37 @@ func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) { } if s.mode == "enforce" && (decision.Action == engine.ActionRequireApproval || decision.Action == engine.ActionAsk) { - if req.OpenClawHosted || req.SkipPendingApproval { - if identity.IsAdmin && req.OpenClawHosted && req.SkipPendingApproval { - s.logger.Info("proxy: trusted OpenClaw-hosted approval evaluation requested, skipping Rampart pending approval creation", + if req.requestsHostedApproval() { + if identity.IsAdmin { + if err := req.validateTrustedHostedApproval(); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.logger.Info("proxy: trusted hosted approval evaluation requested, skipping Rampart pending approval creation", "tool", toolName, "decision", decision.Action.String(), "session", call.Session, + "approval_owner", req.hostedApprovalOwnerMap(), ) + resp["approval_mode"] = "hosted" + if owner := req.hostedApprovalOwnerMap(); len(owner) > 0 { + resp["approval_owner"] = owner + } + if req.ToolCallID != "" { + resp["tool_call_id"] = req.ToolCallID + } + resp["approval"] = s.hostedApprovalDescriptor(req, decision) writeJSON(w, http.StatusOK, resp) return } - s.logger.Warn("proxy: ignoring caller-supplied hosted approval bypass flags for untrusted or incomplete request", + s.logger.Warn("proxy: ignoring caller-supplied hosted approval bypass flags for untrusted request", "tool", toolName, "decision", decision.Action.String(), "session", call.Session, "is_admin", identity.IsAdmin, "openclaw_hosted", req.OpenClawHosted, "skip_pending_approval", req.SkipPendingApproval, + "approval_owner", req.hostedApprovalOwnerMap(), ) } @@ -263,18 +280,22 @@ func (s *Server) applyResponseEvaluation( return true } -func (s *Server) writeAudit(req toolRequest, toolName string, decision engine.Decision) { +func (s *Server) writeAudit(req toolRequest, toolName string, decision engine.Decision) string { if s.sink == nil { - return + return "" } + eventID := audit.NewEventID() event := audit.Event{ - ID: audit.NewEventID(), - Timestamp: time.Now().UTC(), - Agent: req.Agent, - Session: req.Session, - Tool: toolName, - Request: req.Params, + ID: eventID, + Timestamp: time.Now().UTC(), + Agent: req.Agent, + Session: req.Session, + RunID: req.RunID, + ToolCallID: req.ToolCallID, + ApprovalOwner: req.hostedApprovalOwnerMap(), + Tool: toolName, + Request: req.Params, Decision: audit.EventDecision{ Action: decision.Action.String(), MatchedPolicies: decision.MatchedPolicies, @@ -286,6 +307,7 @@ func (s *Server) writeAudit(req toolRequest, toolName string, decision engine.De if err := s.sink.Write(event); err != nil { s.logger.Error("proxy: audit write failed", "error", err) + return "" } s.broadcastSSE(map[string]any{"type": "audit", "event": event}) @@ -306,6 +328,26 @@ func (s *Server) writeAudit(req toolRequest, toolName string, decision engine.De go s.sendWebhook(call, decision) } } + return eventID +} + +func (s *Server) hostedApprovalDescriptor(req toolRequest, decision engine.Decision) map[string]any { + timeout := s.approvalTimeout + if timeout <= 0 { + timeout = 2 * time.Minute + } + scopeOptions := []string{"once", "session"} + allowAlways := req.ApprovalOwner != nil && req.ApprovalOwner.SupportsAllowAlways + if allowAlways { + scopeOptions = append(scopeOptions, "always") + } + return map[string]any{ + "reason": decision.Message, + "scope_options": scopeOptions, + "allow_always_supported": allowAlways, + "timeout_ms": timeout.Milliseconds(), + "expires_at": time.Now().UTC().Add(timeout).Format(time.RFC3339), + } } func (s *Server) shouldNotify(actionStr string) bool { @@ -372,7 +414,7 @@ func (s *Server) handlePreflight(w http.ResponseWriter, r *http.Request) { } decision := s.engine.EvaluateWith(call, evalOpts) allowed := decision.Action == engine.ActionAllow || decision.Action == engine.ActionWatch - s.writeAudit(req, toolName, decision) + auditID := s.writeAudit(req, toolName, decision) preflightResp := map[string]any{ "allowed": allowed, @@ -381,6 +423,9 @@ func (s *Server) handlePreflight(w http.ResponseWriter, r *http.Request) { "matched_policies": decision.MatchedPolicies, "eval_duration_us": decision.EvalDuration.Microseconds(), } + if auditID != "" { + preflightResp["audit_id"] = auditID + } if len(decision.Suggestions) > 0 { preflightResp["suggestions"] = decision.Suggestions } diff --git a/internal/proxy/server.go b/internal/proxy/server.go index dc7fab27..b4165c8c 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -355,6 +355,7 @@ func (s *Server) handler() http.Handler { mux.HandleFunc("GET /v1/approvals/{id}", s.handleGetApproval) mux.HandleFunc("POST /v1/approvals/{id}/resolve", s.handleResolveApproval) mux.HandleFunc("POST /v1/approvals/bulk-resolve", s.handleBulkResolve) + mux.HandleFunc("POST /v1/hosted-approvals/{auditID}/resolve", s.handleResolveHostedApproval) mux.HandleFunc("GET /v1/rules/auto-allowed", s.handleGetAutoAllowed) mux.HandleFunc("DELETE /v1/rules/auto-allowed/{name}", s.handleDeleteAutoAllowed) mux.HandleFunc("POST /v1/rules/learn", s.handleLearnRule) @@ -390,8 +391,10 @@ type toolRequest struct { Agent string `json:"agent"` Session string `json:"session"` RunID string `json:"run_id,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` Params map[string]any `json:"params"` Input map[string]any `json:"input,omitempty"` + ApprovalOwner *approvalOwner `json:"approval_owner,omitempty"` OpenClawHosted bool `json:"openclaw_hosted,omitempty"` SkipPendingApproval bool `json:"skip_pending_approval,omitempty"` @@ -408,6 +411,82 @@ type toolRequest struct { Response string `json:"response,omitempty"` } +// approvalOwner describes host-owned approval/resume capabilities for a tool +// evaluation. approval_owner.mode=hosted means the caller owns the user-facing +// approval object; Rampart records policy/audit data but must not create a +// second Rampart-native pending approval for the same tool call. +type approvalOwner struct { + Host string `json:"host,omitempty"` + Mode string `json:"mode,omitempty"` + Surface string `json:"surface,omitempty"` + SupportsExactResume bool `json:"supports_exact_resume,omitempty"` + SupportsAllowAlways bool `json:"supports_allow_always,omitempty"` + SupportsResultCallback bool `json:"supports_result_callback,omitempty"` +} + +func (o *approvalOwner) isHosted() bool { + return o != nil && strings.EqualFold(strings.TrimSpace(o.Mode), "hosted") +} + +func (o *approvalOwner) toMap() map[string]any { + if o == nil { + return nil + } + out := map[string]any{} + if host := strings.TrimSpace(o.Host); host != "" { + out["host"] = host + } + if mode := strings.TrimSpace(o.Mode); mode != "" { + out["mode"] = mode + } + if surface := strings.TrimSpace(o.Surface); surface != "" { + out["surface"] = surface + } + if o.SupportsExactResume { + out["supports_exact_resume"] = true + } + if o.SupportsAllowAlways { + out["supports_allow_always"] = true + } + if o.SupportsResultCallback { + out["supports_result_callback"] = true + } + if len(out) == 0 { + return nil + } + return out +} + +func (req toolRequest) requestsHostedApproval() bool { + return req.ApprovalOwner.isHosted() || req.OpenClawHosted || req.SkipPendingApproval +} + +func (req toolRequest) hostedApprovalOwnerMap() map[string]any { + if owner := req.ApprovalOwner.toMap(); len(owner) > 0 { + return owner + } + if req.OpenClawHosted { + return map[string]any{"host": "openclaw", "mode": "hosted"} + } + return nil +} + +func (req toolRequest) validateTrustedHostedApproval() error { + if req.ApprovalOwner.isHosted() { + if strings.TrimSpace(req.ApprovalOwner.Host) == "" { + return fmt.Errorf("approval_owner.host is required when approval_owner.mode=hosted") + } + if !req.ApprovalOwner.SupportsExactResume { + return fmt.Errorf("approval_owner.supports_exact_resume=true is required for hosted approvals") + } + return nil + } + if req.OpenClawHosted && req.SkipPendingApproval { + return nil + } + return fmt.Errorf("hosted approval requests require approval_owner.mode=hosted or both openclaw_hosted and skip_pending_approval") +} + // createApprovalRequest is the JSON body for POST /v1/approvals. type createApprovalRequest struct { Tool string `json:"tool"` @@ -424,6 +503,21 @@ type resolveRequest struct { Persist bool `json:"persist"` } +type hostedApprovalResolveRequest struct { + Agent string `json:"agent,omitempty"` + Session string `json:"session,omitempty"` + RunID string `json:"run_id,omitempty"` + Tool string `json:"tool,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + HostApprovalID string `json:"host_approval_id,omitempty"` + ApprovalOwner *approvalOwner `json:"approval_owner,omitempty"` + Outcome string `json:"outcome"` + Scope string `json:"scope,omitempty"` + ResolvedBy string `json:"resolved_by,omitempty"` + ResolvedAt string `json:"resolved_at,omitempty"` + Message string `json:"message,omitempty"` +} + // bulkResolveRequest is the JSON body for POST /v1/approvals/bulk-resolve. type bulkResolveRequest struct { RunID string `json:"run_id"` diff --git a/internal/proxy/server_test.go b/internal/proxy/server_test.go index 8c92a191..0cf282bd 100644 --- a/internal/proxy/server_test.go +++ b/internal/proxy/server_test.go @@ -731,6 +731,131 @@ policies: assert.Len(t, srv.approvals.List(), 1, "agent token must still enqueue Rampart approvals") } +func TestGenericHostedAskSkipsPendingApprovalCreationForAdmin(t *testing.T) { + configYAML := `version: "1" +default_action: deny +policies: + - name: require-human + match: + tool: exec + rules: + - action: ask + when: + command_matches: + - "sudo *" + message: "needs approval" +` + + srv, token, sink := setupTestServer(t, configYAML, "enforce") + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + body := `{"agent":"hermes","session":"discord/thread/test","run_id":"run-1","tool_call_id":"tool-call-1","approval_owner":{"host":"hermes","mode":"hosted","surface":"discord","supports_exact_resume":true,"supports_allow_always":true,"supports_result_callback":true},"params":{"command":"sudo true"}}` + req, err := http.NewRequest(http.MethodPost, ts.URL+"/v1/tool/exec", bytes.NewBufferString(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) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var got map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + assert.Equal(t, "ask", got["decision"]) + assert.Equal(t, "hosted", got["approval_mode"]) + assert.Equal(t, "tool-call-1", got["tool_call_id"]) + require.NotEmpty(t, got["audit_id"]) + _, hasApprovalID := got["approval_id"] + assert.False(t, hasApprovalID, "hosted evaluation must not create Rampart approval_id") + assert.Len(t, srv.approvals.List(), 0, "hosted evaluation must not enqueue Rampart approvals") + + owner, ok := got["approval_owner"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "hermes", owner["host"]) + assert.Equal(t, "hosted", owner["mode"]) + approval, ok := got["approval"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "needs approval", approval["reason"]) + + require.Equal(t, 1, sink.count(), "hosted evaluation should write exactly one policy audit event") + ev := sink.lastEvent() + assert.Equal(t, got["audit_id"], ev.ID) + assert.Equal(t, "tool-call-1", ev.ToolCallID) + assert.Equal(t, "run-1", ev.RunID) + assert.Equal(t, "ask", ev.Decision.Action) + require.NotNil(t, ev.ApprovalOwner) + assert.Equal(t, "hermes", ev.ApprovalOwner["host"]) +} + +func TestHostedAskRequiresExactResumeForAdmin(t *testing.T) { + configYAML := `version: "1" +default_action: deny +policies: + - name: require-human + match: + tool: exec + rules: + - action: ask + when: + command_matches: + - "sudo *" + message: "needs approval" +` + + srv, token, _ := setupTestServer(t, configYAML, "enforce") + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + body := `{"agent":"hermes","session":"discord/thread/test","approval_owner":{"host":"hermes","mode":"hosted"},"params":{"command":"sudo true"}}` + req, err := http.NewRequest(http.MethodPost, ts.URL+"/v1/tool/exec", bytes.NewBufferString(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) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Len(t, srv.approvals.List(), 0, "invalid hosted request must not fall back to hidden Rampart approval queue") +} + +func TestHostedApprovalResolveRecordsAuditWithoutPendingApproval(t *testing.T) { + srv, token, sink := setupTestServer(t, testPolicyYAML, "enforce") + ts := httptest.NewServer(srv.handler()) + defer ts.Close() + + body := `{"agent":"hermes","session":"discord/thread/test","run_id":"run-1","tool":"exec","tool_call_id":"tool-call-1","host_approval_id":"hermes-approval-1","approval_owner":{"host":"hermes","mode":"hosted","surface":"discord","supports_exact_resume":true},"outcome":"approved","scope":"once","resolved_by":"trevor","message":"approved in Hermes"}` + req, err := http.NewRequest(http.MethodPost, ts.URL+"/v1/hosted-approvals/audit-1/resolve", bytes.NewBufferString(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) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var got map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + assert.Equal(t, "audit-1", got["audit_id"]) + assert.Equal(t, "recorded", got["status"]) + assert.Equal(t, "approved", got["outcome"]) + assert.Len(t, srv.approvals.List(), 0, "hosted resolution callback must not create Rampart pending approvals") + + require.Equal(t, 1, sink.count()) + ev := sink.lastEvent() + assert.Equal(t, "exec", ev.Tool) + assert.Equal(t, "tool-call-1", ev.ToolCallID) + assert.Equal(t, "run-1", ev.RunID) + assert.Equal(t, "approved", ev.Decision.Action) + assert.Equal(t, "approved in Hermes", ev.Decision.Message) + assert.Equal(t, "hosted_approval_resolved", ev.Request["action"]) + assert.Equal(t, "audit-1", ev.Request["audit_id"]) + assert.Equal(t, "hermes-approval-1", ev.Request["host_approval_id"]) +} + func TestUserOverridesBypassApprovalQueue(t *testing.T) { tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) From 5f26ffe443d89fbaf504705fdb87cbd5e623221a Mon Sep 17 00:00:00 2001 From: "clap [bot]" Date: Wed, 27 May 2026 18:43:51 +0000 Subject: [PATCH 2/9] feat: correlate Hermes plugin audit IDs --- docs-site/integrations/hermes.md | 12 ++++---- internal/plugin/hermes/__init__.py | 29 ++++++++++++++------ internal/plugin/hermes/embed_test.go | 2 +- internal/plugin/hermes/plugin.yaml | 2 +- internal/plugin/hermes/test_hermes_plugin.py | 16 +++++++++-- 5 files changed, 42 insertions(+), 19 deletions(-) diff --git a/docs-site/integrations/hermes.md b/docs-site/integrations/hermes.md index 9d2ab233..97fef116 100644 --- a/docs-site/integrations/hermes.md +++ b/docs-site/integrations/hermes.md @@ -5,7 +5,7 @@ description: "Experimental Rampart integration for Hermes Agent using a user plu # Hermes Agent -Rampart can protect Hermes Agent through an **experimental user plugin**. The plugin registers a Hermes `pre_tool_call` hook, sends a sanitized policy check to Rampart before selected tools execute, and blocks the tool call when policy denies it. +Rampart can protect Hermes Agent through an **experimental user plugin**. The plugin registers a Hermes `pre_tool_call` hook, sends a sanitized policy check to Rampart before selected tools execute, passes Hermes' top-level `tool_call_id` for audit correlation, and blocks the tool call when policy denies it. !!! warning "Experimental integration" This integration is intentionally conservative. It does not patch Hermes, does not create a hidden approval queue, and does not resume `ask` decisions automatically. Policies that return `ask` are blocked with an approval-required message until Hermes has a first-class plugin approval/resume flow. @@ -84,18 +84,20 @@ plugins: | --- | --- | | `allow`, `watch`, `log` | Tool call continues. | | `deny` | Tool call is blocked with the policy reason. | -| `ask`, `require_approval` | Tool call is blocked with an approval-required message. No hidden Rampart approval is created by default. | +| `ask`, `require_approval` | Tool call is blocked with an approval-required message. No hidden Rampart approval is created by default. When Rampart returns an `audit_id`, the block message includes it for correlation. | | Rampart unavailable | Mutating/high-risk tools fail closed; configured read-only tools may fail open. | -The default endpoint mode is `preflight`, which calls `POST /v1/preflight/{tool}`. This is deliberate: it avoids creating pending Rampart approvals that Hermes cannot yet resume from a plugin hook. +The default endpoint mode is `preflight`, which calls `POST /v1/preflight/{tool}`. This is deliberate: it avoids creating pending Rampart approvals that Hermes cannot yet resume from a plugin hook. Rampart records the evaluation audit ID, and the plugin sends Hermes' top-level `tool_call_id` when Hermes provides one. -For experiments that need full `/v1/tool/{tool}` semantics, set: +Rampart's hosted approval API is ready for hosts that can create a single user-facing approval and resume the exact tool call. This plugin does not request hosted approvals yet because current Hermes plugin hooks do not expose that exact approval/resume contract as a stable plugin primitive. + +For experiments that need raw `/v1/tool/{tool}` semantics, set: ```bash export RAMPART_HERMES_ENDPOINT_MODE=tool ``` -Use this only when you understand the approval ownership tradeoff. +Use this only when you understand the approval ownership tradeoff. If a policy returns `ask` in raw tool mode, Rampart may create a Rampart-native pending approval that Hermes cannot resume; keep the default `preflight` mode for normal Hermes testing. ## Verification diff --git a/internal/plugin/hermes/__init__.py b/internal/plugin/hermes/__init__.py index 9bf86e22..06d02791 100644 --- a/internal/plugin/hermes/__init__.py +++ b/internal/plugin/hermes/__init__.py @@ -7,8 +7,11 @@ * It uses Hermes' ``pre_tool_call`` hook and only returns a block directive. * It defaults to ``/v1/preflight/{tool}`` so Rampart does not create a hidden approval queue while Hermes lacks a plugin-owned approval/resume primitive. +* It passes Hermes' native ``tool_call_id`` as top-level Rampart metadata so + Rampart audit IDs can be correlated with the exact Hermes tool call. * ``ask`` / ``require_approval`` decisions block with a clear message instead - of polling or creating a second approval surface. + of polling or creating a second approval surface, and include Rampart's + ``audit_id`` when available. * If Rampart serve is unavailable, mutating/high-risk tools fail closed and only explicitly configured read-only tools fail open. """ @@ -26,7 +29,7 @@ from typing import Any, Callable, Mapping from urllib.parse import quote -VERSION = "0.1.0" +VERSION = "0.1.1" DEFAULT_SERVE_URL = "http://127.0.0.1:9090" DEFAULT_TIMEOUT_MS = 3000 @@ -399,15 +402,15 @@ def _build_payload( task_id: str = "", tool_call_id: str = "", ) -> dict[str, Any]: - payload_params = dict(params) - if tool_call_id: - payload_params["hermes_tool_call_id"] = tool_call_id - return { + payload: dict[str, Any] = { "agent": config.agent_name, "session": session_id or "", "run_id": task_id or "", - "params": payload_params, + "params": dict(params), } + if tool_call_id: + payload["tool_call_id"] = tool_call_id + return payload def _endpoint_url(config: PluginConfig, rampart_tool: str) -> str: @@ -471,6 +474,13 @@ def _decision_from_result(result: Mapping[str, Any]) -> str: return "allow" +def _audit_suffix(result: Mapping[str, Any]) -> str: + audit_id = result.get("audit_id") + if isinstance(audit_id, str) and audit_id.strip(): + return f" [audit_id: {audit_id.strip()}]" + return "" + + def evaluate_pre_tool_call( tool_name: str, args: Mapping[str, Any] | None, @@ -510,6 +520,7 @@ def evaluate_pre_tool_call( reason = str(result.get("message") or result.get("reason") or "policy violation") policy = result.get("policy") or result.get("matched_policies") + audit_suffix = _audit_suffix(result) policy_suffix = "" if isinstance(policy, str) and policy: policy_suffix = f" [policy: {policy}]" @@ -519,12 +530,12 @@ def evaluate_pre_tool_call( if decision in {"ask", "require_approval"}: return _block( "rampart: approval required" - f" for {tool_name}→{rampart_tool}{policy_suffix} — {reason}. " + f" for {tool_name}→{rampart_tool}{policy_suffix}{audit_suffix} — {reason}. " "Hermes Rampart integration is experimental and does not yet resume plugin-driven approvals; " "adjust policy or use a first-class Hermes approval flow before retrying." ) - return _block(f"rampart: {reason}{policy_suffix}") + return _block(f"rampart: {reason}{policy_suffix}{audit_suffix}") def register(ctx: Any) -> None: diff --git a/internal/plugin/hermes/embed_test.go b/internal/plugin/hermes/embed_test.go index eee9ee03..6d10c551 100644 --- a/internal/plugin/hermes/embed_test.go +++ b/internal/plugin/hermes/embed_test.go @@ -11,7 +11,7 @@ import ( ) func TestVersionReadsManifest(t *testing.T) { - if got, want := Version(), "0.1.0"; got != want { + if got, want := Version(), "0.1.1"; got != want { t.Fatalf("Version() = %q, want %q", got, want) } } diff --git a/internal/plugin/hermes/plugin.yaml b/internal/plugin/hermes/plugin.yaml index 1111227c..b2aaca72 100644 --- a/internal/plugin/hermes/plugin.yaml +++ b/internal/plugin/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: rampart -version: 0.1.0 +version: 0.1.1 description: Experimental Rampart policy gate for Hermes Agent pre_tool_call hooks author: peg provides_hooks: diff --git a/internal/plugin/hermes/test_hermes_plugin.py b/internal/plugin/hermes/test_hermes_plugin.py index 65e05067..c0d93a77 100644 --- a/internal/plugin/hermes/test_hermes_plugin.py +++ b/internal/plugin/hermes/test_hermes_plugin.py @@ -88,7 +88,7 @@ def requester(config, rampart_tool, payload): captured["config"] = config captured["tool"] = rampart_tool captured["payload"] = payload - return {"decision": "deny", "message": "blocked", "policy": "danger"} + return {"decision": "deny", "message": "blocked", "policy": "danger", "audit_id": "audit-deny-1"} result = plugin.evaluate_pre_tool_call( "terminal", @@ -104,21 +104,30 @@ def requester(config, rampart_tool, payload): self.assertEqual(captured["payload"]["agent"], "hermes") self.assertEqual(captured["payload"]["session"], "sess-1") self.assertEqual(captured["payload"]["run_id"], "task-1") - self.assertEqual(captured["payload"]["params"]["hermes_tool_call_id"], "call-1") + self.assertEqual(captured["payload"]["tool_call_id"], "call-1") + self.assertNotIn("hermes_tool_call_id", captured["payload"]["params"]) self.assertEqual(result["action"], "block") self.assertIn("blocked", result["message"]) self.assertIn("danger", result["message"]) + self.assertIn("audit-deny-1", result["message"]) def test_ask_decision_blocks_without_hidden_approval(self) -> None: def requester(config, rampart_tool, payload): self.assertEqual(config.endpoint_mode, "preflight") self.assertNotIn("openclaw_hosted", payload) self.assertNotIn("skip_pending_approval", payload) - return {"decision": "ask", "message": "needs human review", "matched_policies": ["prod-change"]} + self.assertEqual(payload["tool_call_id"], "call-ask-1") + return { + "decision": "ask", + "message": "needs human review", + "matched_policies": ["prod-change"], + "audit_id": "audit-ask-1", + } result = plugin.evaluate_pre_tool_call( "terminal", {"command": "kubectl apply -f prod.yaml"}, + tool_call_id="call-ask-1", requester=requester, ) @@ -126,6 +135,7 @@ def requester(config, rampart_tool, payload): self.assertIn("approval required", result["message"]) self.assertIn("does not yet resume", result["message"]) self.assertIn("prod-change", result["message"]) + self.assertIn("audit-ask-1", result["message"]) def test_unavailable_blocks_mutating_tool(self) -> None: def requester(config, rampart_tool, payload): From ea66738e476918f3026ee79d8c83168c7d51a09b Mon Sep 17 00:00:00 2001 From: "clap [bot]" Date: Wed, 27 May 2026 20:26:17 +0000 Subject: [PATCH 3/9] fix: preserve OpenClaw plugin discovery defaults --- cmd/rampart/cli/doctor.go | 40 ++++++++++++ cmd/rampart/cli/doctor_test.go | 56 +++++++++++++++++ cmd/rampart/cli/setup.go | 3 +- cmd/rampart/cli/setup_openclaw_plugin.go | 44 ++++++++----- cmd/rampart/cli/setup_openclaw_plugin_test.go | 63 +++++++++++++++++++ cmd/rampart/cli/status_test.go | 5 ++ 6 files changed, 195 insertions(+), 16 deletions(-) diff --git a/cmd/rampart/cli/doctor.go b/cmd/rampart/cli/doctor.go index 59d922db..658de6eb 100644 --- a/cmd/rampart/cli/doctor.go +++ b/cmd/rampart/cli/doctor.go @@ -294,6 +294,9 @@ func runDoctor(w io.Writer, jsonOut bool) error { if n := doctorOpenClawPlugin(emit); n > 0 { warnings += n } + if n := doctorOpenClawProviderDiscovery(emit); n > 0 { + warnings += n + } if n := doctorOpenClawReadiness(emit, pluginActive, serveURL, token); n > 0 { warnings += n } @@ -1655,6 +1658,43 @@ func doctorOpenClawPlugin(emit emitFn) (warnings int) { return 0 } +func doctorOpenClawProviderDiscovery(emit emitFn) (warnings int) { + if !isOpenClawInstalled() { + return 0 + } + bin, err := findOpenClawBinary() + if err != nil { + return 0 + } + _, configPath, err := resolveOpenClawStateDir(bin) + if err != nil { + return 0 + } + data, err := os.ReadFile(configPath) + if err != nil { + return 0 + } + var cfg struct { + Plugins struct { + Allow *[]string `json:"allow"` + BundledDiscovery string `json:"bundledDiscovery"` + } `json:"plugins"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + return 0 + } + if cfg.Plugins.Allow == nil { + return 0 + } + if strings.EqualFold(strings.TrimSpace(cfg.Plugins.BundledDiscovery), "allowlist") { + return 0 + } + emit("OpenClaw provider discovery", "warn", + "plugins.allow is restrictive, but bundled provider discovery is still in compatibility mode"+hintSep+ + "After confirming plugins.allow includes every bundled provider you intend to keep, run: openclaw config set plugins.bundledDiscovery allowlist") + return 1 +} + func isReleaseVersion(version string) bool { _, ok := normalizedReleaseVersion(version) return ok diff --git a/cmd/rampart/cli/doctor_test.go b/cmd/rampart/cli/doctor_test.go index dc756c02..7d8f9c95 100644 --- a/cmd/rampart/cli/doctor_test.go +++ b/cmd/rampart/cli/doctor_test.go @@ -651,6 +651,9 @@ func TestDoctorOpenClawPlugin(t *testing.T) { requireNoErr(t, os.WriteFile(filepath.Join(binDir, "openclaw"), []byte("#!/bin/sh\nexit 0\n"), 0o755)) t.Setenv("PATH", binDir) requireNoErr(t, os.MkdirAll(filepath.Join(home, ".openclaw", "extensions", "rampart"), 0o755)) + pluginDir := filepath.Join(home, ".openclaw", "extensions", "rampart") + requireNoErr(t, os.WriteFile(filepath.Join(pluginDir, "openclaw.plugin.json"), []byte(`{"version":"1.0.0","activation":{"onStartup":true}}`), 0o600)) + requireNoErr(t, os.WriteFile(filepath.Join(pluginDir, "index.js"), []byte(`export const version = "1.0.0";`), 0o600)) return home } @@ -664,6 +667,17 @@ func TestDoctorOpenClawPlugin(t *testing.T) { return warnings, results } + t.Run("allows plugin when plugins.allow is absent", func(t *testing.T) { + home := setup(t) + requireNoErr(t, os.MkdirAll(filepath.Join(home, ".openclaw"), 0o755)) + requireNoErr(t, os.WriteFile(filepath.Join(home, ".openclaw", "openclaw.json"), []byte(`{"plugins":{"entries":{"rampart":{"enabled":true}}}}`), 0o600)) + + warnings, results := run(t) + if warnings != 0 || len(results) != 1 || results[0].Status != "ok" { + t.Fatalf("expected ok, got warnings=%d results=%+v", warnings, results) + } + }) + t.Run("warns when plugin missing from allow list", func(t *testing.T) { home := setup(t) requireNoErr(t, os.MkdirAll(filepath.Join(home, ".openclaw"), 0o755)) @@ -707,6 +721,48 @@ func TestDoctorOpenClawPlugin(t *testing.T) { }) } +func TestDoctorOpenClawProviderDiscovery(t *testing.T) { + skipOnWindows(t, "PATH shim binaries in this test are Unix-only") + + setup := func(t *testing.T, config string) []checkResult { + t.Helper() + home := t.TempDir() + testSetHome(t, home) + binDir := t.TempDir() + requireNoErr(t, os.WriteFile(filepath.Join(binDir, "openclaw"), []byte("#!/bin/sh\nexit 0\n"), 0o755)) + t.Setenv("PATH", binDir) + requireNoErr(t, os.MkdirAll(filepath.Join(home, ".openclaw", "extensions", "rampart"), 0o755)) + requireNoErr(t, os.WriteFile(filepath.Join(home, ".openclaw", "openclaw.json"), []byte(config), 0o600)) + var results []checkResult + doctorOpenClawProviderDiscovery(func(name, status, msg string) { + results = append(results, checkResult{Name: name, Status: status, Message: msg}) + }) + return results + } + + t.Run("warns for restrictive allowlist with compatibility discovery", func(t *testing.T) { + results := setup(t, `{"plugins":{"allow":["rampart","codex"],"bundledDiscovery":"compat"}}`) + if len(results) != 1 || results[0].Status != "warn" { + t.Fatalf("expected one warning, got %+v", results) + } + if !strings.Contains(results[0].Message, "bundled provider discovery") { + t.Fatalf("expected provider discovery warning, got %s", results[0].Message) + } + }) + + t.Run("skips when allowlist absent", func(t *testing.T) { + if results := setup(t, `{"plugins":{"entries":{"rampart":{"enabled":true}}}}`); len(results) != 0 { + t.Fatalf("expected no warning, got %+v", results) + } + }) + + t.Run("skips when allowlist mode configured", func(t *testing.T) { + if results := setup(t, `{"plugins":{"allow":["rampart"],"bundledDiscovery":"allowlist"}}`); len(results) != 0 { + t.Fatalf("expected no warning, got %+v", results) + } + }) +} + func TestDoctorOpenClawReadiness(t *testing.T) { t.Run("skips when plugin inactive", func(t *testing.T) { var results []checkResult diff --git a/cmd/rampart/cli/setup.go b/cmd/rampart/cli/setup.go index 01f2969d..b8dd445b 100644 --- a/cmd/rampart/cli/setup.go +++ b/cmd/rampart/cli/setup.go @@ -435,7 +435,8 @@ func newSetupOpenClawCmd(opts *rootOptions) *cobra.Command { Default behavior on current OpenClaw builds (>= 2026.3.28): - Installs the native Rampart plugin via "openclaw plugins install" - Ensures rampart serve is available for policy evaluation and approvals - - Adds rampart to plugins.allow and installs the OpenClaw policy profile + - Preserves OpenClaw's plugin discovery defaults; if plugins.allow already exists, adds rampart to it + - Installs the OpenClaw policy profile - Preserves OpenClaw's native approval UI while Rampart evaluates policy Legacy compatibility options still exist for older OpenClaw setups: diff --git a/cmd/rampart/cli/setup_openclaw_plugin.go b/cmd/rampart/cli/setup_openclaw_plugin.go index a2f243a1..002ccd5f 100644 --- a/cmd/rampart/cli/setup_openclaw_plugin.go +++ b/cmd/rampart/cli/setup_openclaw_plugin.go @@ -123,11 +123,15 @@ func runSetupOpenClawPlugin(w io.Writer, errW io.Writer) error { fmt.Fprintln(w, "✓ Set tools.exec.ask = \"off\" (OpenClaw keeps native approval ownership; Rampart evaluates policy behind it)") } - // 4b. Add rampart to plugins.allow. Existing plugins are preserved — we only append. + // 4b. Preserve OpenClaw's default plugin discovery unless the user already + // configured a restrictive plugins.allow list. When the allowlist exists, + // append only — never remove or overwrite existing plugin IDs. if added, existing, err := addToOpenClawPluginsAllow("rampart"); err != nil { fmt.Fprintf(errW, "⚠ Could not update plugins.allow in openclaw.json: %v\n", err) } else if added { - fmt.Fprintf(w, "✓ Added rampart to plugins.allow (existing: %v)\n", existing) + fmt.Fprintf(w, "✓ Added rampart to existing plugins.allow (existing: %v)\n", existing) + } else if existing == nil { + fmt.Fprintln(w, "✓ plugins.allow is not configured; left OpenClaw's default plugin discovery unchanged") } else { fmt.Fprintln(w, "✓ rampart already in plugins.allow (no changes to other plugins)") } @@ -575,10 +579,11 @@ func parseCalVer(v string) []int { return result } -// setOpenClawExecAsk sets tools.exec.ask in the active OpenClaw config. -// addToOpenClawPluginsAllow adds pluginID to the plugins.allow list in openclaw.json -// if it is not already present. Returns (added, existingIDs, error). -// NEVER removes or overwrites existing entries — only appends. +// addToOpenClawPluginsAllow adds pluginID to an existing plugins.allow list in +// openclaw.json if it is not already present. If plugins.allow is absent, +// OpenClaw's default discovery remains unrestricted, so this function leaves the +// config unchanged and returns added=false, existing=nil. Existing entries are +// never removed or overwritten. func addToOpenClawPluginsAllow(pluginID string) (added bool, existing []string, err error) { bin, berr := findOpenClawBinary() if berr != nil { @@ -598,10 +603,16 @@ func addToOpenClawPluginsAllow(pluginID string) (added bool, existing []string, } plugins, _ := cfg["plugins"].(map[string]any) if plugins == nil { - plugins = map[string]any{} - cfg["plugins"] = plugins + return false, nil, nil + } + allowValue, allowExists := plugins["allow"] + if !allowExists { + return false, nil, nil + } + allowRaw, ok := allowValue.([]any) + if !ok { + return false, nil, fmt.Errorf("plugins.allow must be a JSON array when present") } - allowRaw, _ := plugins["allow"].([]any) // Collect existing string entries and check for duplicates. for _, v := range allowRaw { if s, ok := v.(string); ok { @@ -820,7 +831,7 @@ func getOpenClawPluginState() openClawPluginState { var cfg struct { Plugins struct { - Allow []string `json:"allow"` + Allow *[]string `json:"allow"` Entries map[string]struct { Enabled *bool `json:"enabled"` } `json:"entries"` @@ -830,11 +841,14 @@ func getOpenClawPluginState() openClawPluginState { return state } - state.Allowed = false - for _, id := range cfg.Plugins.Allow { - if id == "rampart" { - state.Allowed = true - break + state.Allowed = true + if cfg.Plugins.Allow != nil { + state.Allowed = false + for _, id := range *cfg.Plugins.Allow { + if id == "rampart" { + state.Allowed = true + break + } } } if entry, ok := cfg.Plugins.Entries["rampart"]; ok && entry.Enabled != nil { diff --git a/cmd/rampart/cli/setup_openclaw_plugin_test.go b/cmd/rampart/cli/setup_openclaw_plugin_test.go index 49d70151..be9e34a5 100644 --- a/cmd/rampart/cli/setup_openclaw_plugin_test.go +++ b/cmd/rampart/cli/setup_openclaw_plugin_test.go @@ -78,3 +78,66 @@ func TestResolveOpenClawStateDirHonorsConfigEnv(t *testing.T) { t.Fatalf("stateDir/configPath = %q/%q, want %q/%q", stateDir, configPath, tmp, cfg) } } + +func TestAddToOpenClawPluginsAllowPreservesAbsentAllowlist(t *testing.T) { + skipOnWindows(t, "PATH shim binaries in this test are Unix-only") + configPath := setupOpenClawConfigTest(t, `{"plugins":{"entries":{"rampart":{"enabled":true}}}}`) + before, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + + added, existing, err := addToOpenClawPluginsAllow("rampart") + if err != nil { + t.Fatalf("addToOpenClawPluginsAllow returned error: %v", err) + } + if added || existing != nil { + t.Fatalf("expected no change with nil existing allowlist, got added=%v existing=%v", added, existing) + } + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatalf("config changed when plugins.allow was absent:\nbefore=%s\nafter=%s", before, after) + } +} + +func TestAddToOpenClawPluginsAllowAppendsExistingAllowlist(t *testing.T) { + skipOnWindows(t, "PATH shim binaries in this test are Unix-only") + configPath := setupOpenClawConfigTest(t, `{"plugins":{"allow":["codex"]}}`) + + added, existing, err := addToOpenClawPluginsAllow("rampart") + if err != nil { + t.Fatalf("addToOpenClawPluginsAllow returned error: %v", err) + } + if !added || len(existing) != 1 || existing[0] != "codex" { + t.Fatalf("expected append after existing codex allowlist, got added=%v existing=%v", added, existing) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"codex"`, `"rampart"`} { + if !strings.Contains(string(data), want) { + t.Fatalf("updated config missing %s: %s", want, data) + } + } +} + +func setupOpenClawConfigTest(t *testing.T, config string) string { + t.Helper() + stateDir := t.TempDir() + binDir := t.TempDir() + bin := filepath.Join(binDir, "openclaw") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("RAMPART_OPENCLAW_BIN", bin) + t.Setenv("OPENCLAW_STATE_DIR", stateDir) + configPath := filepath.Join(stateDir, "openclaw.json") + if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil { + t.Fatal(err) + } + return configPath +} diff --git a/cmd/rampart/cli/status_test.go b/cmd/rampart/cli/status_test.go index 68524c35..d27d0cf5 100644 --- a/cmd/rampart/cli/status_test.go +++ b/cmd/rampart/cli/status_test.go @@ -228,6 +228,11 @@ func TestDetectProtectedAgents_OpenClawPluginRequiresAllowedAndEnabled(t *testin t.Fatal("OpenClaw should not be reported when plugins.allow is missing rampart") } + mustWrite(`{"plugins":{"entries":{"rampart":{"enabled":true}}}}`) + if !contains("OpenClaw (plugin)") { + t.Fatal("expected plugin to be reported when plugins.allow is absent") + } + mustWrite(`{"plugins":{"allow":["rampart"],"entries":{"rampart":{"enabled":false}}}}`) if containsOpenClaw() { t.Fatal("OpenClaw should not be reported when plugins.entries.rampart.enabled=false") From 80a4c40a560925586be4687620ffaad1f275eb5a Mon Sep 17 00:00:00 2001 From: "clap [bot]" Date: Thu, 28 May 2026 06:34:07 +0000 Subject: [PATCH 4/9] fix: harden audit recovery for v1.2.0 release --- CHANGELOG.md | 17 ++ cmd/rampart/cli/audit.go | 18 +- cmd/rampart/cli/audit_helpers.go | 9 +- cmd/rampart/cli/audit_test.go | 40 +++++ docs-site/getting-started/installation.md | 2 +- docs-site/getting-started/troubleshooting.md | 2 +- docs-site/index.html | 4 +- docs-site/index.md | 9 +- docs-site/integrations/openclaw.md | 2 +- docs-site/reference/threat-model.md | 2 +- docs/ROADMAP.md | 10 +- docs/index.html | 4 +- docs/install | 4 +- docs/install.sh | 4 +- install.sh | 4 +- internal/audit/jsonl.go | 157 ++++++++++-------- internal/audit/jsonl_test.go | 98 ++++++++++- internal/plugin/hermes/__init__.py | 2 +- internal/plugin/hermes/embed_test.go | 2 +- internal/plugin/hermes/plugin.yaml | 2 +- internal/plugin/openclaw/index.js | 4 +- internal/plugin/openclaw/openclaw.plugin.json | 2 +- internal/plugin/openclaw/package.json | 2 +- policies/openclaw.yaml | 2 +- scripts/install.sh | 4 +- 25 files changed, 299 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ef58ae..3ada03f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-05-28 + +### Added + +- **Hosted approval API foundation for host-owned approval flows** — Rampart can return hosted approval metadata without creating a hidden Rampart pending queue item, preserving the single approval-owner boundary for hosts such as Hermes. +- **Hermes audit/tool-call correlation** — The experimental Hermes policy gate passes Hermes tool-call metadata through Rampart so audit entries can be correlated with the originating Hermes tool call. + +### Changed + +- **Bundled plugin metadata is aligned for v1.2.0** — The OpenClaw and experimental Hermes plugin manifests, runtime exports, and public examples now report `1.2.0` with the Rampart release. +- **Release-facing docs and install examples now point at v1.2.0** — Current-version markers, troubleshooting examples, and container tag examples are refreshed for the release. + +### Fixed + +- **Audit chain recovery now resumes from the latest valid JSONL event** — Startup reconstruction recovers both the event count and chain head from existing audit logs instead of trusting absent, stale, or tampered anchors as the next `prev_hash` source. +- **Partial audit verification is safer** — `rampart audit verify --since` accepts intentionally truncated history while continuing to verify the included hash chain and anchor data that is present in the selected window. + ## [1.1.1] - 2026-05-26 ### Added diff --git a/cmd/rampart/cli/audit.go b/cmd/rampart/cli/audit.go index d0c32745..6b9d6220 100644 --- a/cmd/rampart/cli/audit.go +++ b/cmd/rampart/cli/audit.go @@ -152,7 +152,8 @@ Example: return fmt.Errorf("audit: no .jsonl files found in %s", auditDir) } - // Filter files by --since date if provided + // Filter files by --since date if provided. Size-rotated files use + // YYYY-MM-DD.pN.jsonl names, so compare only the leading date. if since != "" { sinceDate, parseErr := time.Parse("2006-01-02", since) if parseErr != nil { @@ -161,11 +162,13 @@ Example: filtered := files[:0] for _, f := range files { base := filepath.Base(f) - // Filename format: YYYY-MM-DD.jsonl datePart := strings.TrimSuffix(base, ".jsonl") + if len(datePart) >= len("2006-01-02") { + datePart = datePart[:len("2006-01-02")] + } fileDate, dateErr := time.Parse("2006-01-02", datePart) if dateErr != nil { - // Can't parse date from filename — include it to be safe + // Can't parse date from filename — include it to be safe. filtered = append(filtered, f) continue } @@ -179,12 +182,12 @@ Example: } } - count, hashesByID, err := verifyAuditChain(files) + count, hashesByID, err := verifyAuditChain(files, since != "") if err != nil { return err } - if err := verifyAnchors(auditDir, hashesByID); err != nil { + if err := verifyAnchors(auditDir, hashesByID, since == ""); err != nil { return err } @@ -200,7 +203,8 @@ Example: return cmd } -func verifyAuditChain(files []string) (int, map[string]string, error) { +func verifyAuditChain(files []string, allowInitialPrev ...bool) (int, map[string]string, error) { + partialChain := len(allowInitialPrev) > 0 && allowInitialPrev[0] prevHash := "" eventCount := 0 hashesByID := map[string]string{} @@ -208,7 +212,7 @@ func verifyAuditChain(files []string) (int, map[string]string, error) { for _, file := range files { scanErr := scanAuditEvents(file, func(event audit.Event) error { eventCount++ - if eventCount == 1 && event.PrevHash != "" { + if eventCount == 1 && event.PrevHash != "" && !partialChain { return fmt.Errorf("audit: CHAIN BROKEN at event %s in file %s: first event has non-empty prev_hash", event.ID, filepath.Base(file)) } if eventCount > 1 && event.PrevHash != prevHash { diff --git a/cmd/rampart/cli/audit_helpers.go b/cmd/rampart/cli/audit_helpers.go index 3160bc4c..9c6ce30e 100644 --- a/cmd/rampart/cli/audit_helpers.go +++ b/cmd/rampart/cli/audit_helpers.go @@ -320,7 +320,11 @@ func eventMatchesQuery(event audit.Event, query string) bool { return false } -func verifyAnchors(auditDir string, hashesByID map[string]string) error { +func verifyAnchors(auditDir string, hashesByID map[string]string, strictOpt ...bool) error { + strict := true + if len(strictOpt) > 0 { + strict = strictOpt[0] + } anchors, err := listAnchorFiles(auditDir) if err != nil { return err @@ -342,6 +346,9 @@ func verifyAnchors(auditDir string, hashesByID map[string]string) error { hash, ok := hashesByID[anchor.EventID] if !ok { + if !strict { + continue + } return fmt.Errorf("audit: CHAIN BROKEN at event %s in file %s: anchor event not found", anchor.EventID, filepath.Base(anchorFile)) } if hash != anchor.Hash { diff --git a/cmd/rampart/cli/audit_test.go b/cmd/rampart/cli/audit_test.go index db82b42e..b92ae026 100644 --- a/cmd/rampart/cli/audit_test.go +++ b/cmd/rampart/cli/audit_test.go @@ -110,6 +110,46 @@ func TestAuditVerify_ValidChain(t *testing.T) { assert.Contains(t, stdout, "10 events") } +func TestAuditVerifySince_AllowsPartialChainAndSkippedAnchor(t *testing.T) { + dir := t.TempDir() + + first := makeEvent("exec", "old", "main", "allow", "ok") + first.ID = audit.NewEventID() + first.Timestamp = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) + require.NoError(t, first.ComputeHash()) + + second := makeEvent("exec", "new", "main", "allow", "ok") + second.ID = audit.NewEventID() + second.Timestamp = time.Date(2026, 2, 9, 12, 0, 0, 0, time.UTC) + second.PrevHash = first.Hash + require.NoError(t, second.ComputeHash()) + + writeSingleAuditEvent := func(name string, event audit.Event) { + t.Helper() + data, err := json.Marshal(event) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), append(data, '\n'), 0o644)) + } + writeSingleAuditEvent("2026-02-08.jsonl", first) + writeSingleAuditEvent("2026-02-09.jsonl", second) + + anchor := audit.ChainAnchor{ + EventID: first.ID, + Hash: first.Hash, + EventCount: 1, + Timestamp: first.Timestamp, + File: "2026-02-08.jsonl", + } + anchorData, err := json.Marshal(anchor) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "audit-anchor.json"), anchorData, 0o644)) + + stdout, _, err := runCLI(t, "audit", "verify", "--audit-dir", dir, "--since", "2026-02-09") + require.NoError(t, err) + assert.Contains(t, stdout, "1 events") + assert.Contains(t, stdout, "no tampering detected") +} + func TestAuditVerify_BrokenChain(t *testing.T) { dir := t.TempDir() events := make([]audit.Event, 5) diff --git a/docs-site/getting-started/installation.md b/docs-site/getting-started/installation.md index bf25e857..29b4aa8b 100644 --- a/docs-site/getting-started/installation.md +++ b/docs-site/getting-started/installation.md @@ -97,7 +97,7 @@ volumes: rampart-audit: ``` -Available tags include full versions such as `1.1.1`, minor versions such as `1.1`, and `latest` for the current stable release. Prereleases use their full tag, for example `1.1.0-rc.1`, and do not move `latest`. Pin to a specific version tag for reproducibility. Images are published on [GitHub Container Registry](https://github.com/peg/rampart/pkgs/container/rampart). +Available tags include full versions such as `1.2.0`, minor versions such as `1.2`, and `latest` for the current stable release. Prereleases use their full tag, for example `1.2.0-rc.1`, and do not move `latest`. Pin to a specific version tag for reproducibility. Images are published on [GitHub Container Registry](https://github.com/peg/rampart/pkgs/container/rampart). ## Build from Source diff --git a/docs-site/getting-started/troubleshooting.md b/docs-site/getting-started/troubleshooting.md index a1f6b39e..00fd161f 100644 --- a/docs-site/getting-started/troubleshooting.md +++ b/docs-site/getting-started/troubleshooting.md @@ -165,7 +165,7 @@ rampart test --tool read "/etc/shadow" ```bash openclaw plugins list -# Should show: rampart 1.1.1 ✓ active +# Should show: rampart 1.2.0 ✓ active rampart doctor # Should show: ✓ OpenClaw plugin: installed (before_tool_call hook active) diff --git a/docs-site/index.html b/docs-site/index.html index 0b87de42..b986426d 100644 --- a/docs-site/index.html +++ b/docs-site/index.html @@ -27,7 +27,7 @@ "url": "https://rampart.sh", "applicationCategory": "SecurityApplication", "operatingSystem": "Linux, macOS, Windows", - "softwareVersion": "1.1.1", + "softwareVersion": "1.2.0", "offers": { "@type": "Offer", "price": "0", @@ -1112,7 +1112,7 @@
-
v1.1.1 · release-ready
+
v1.2.0 · release-ready

Your agent has root.
That’s the problem.

Rampart sits in the execution path. Install it, run rampart quickstart, and it wires the right protection path for Claude Code, Codex, Cline, or OpenClaw before risky tool calls run.

diff --git a/docs-site/index.md b/docs-site/index.md index 38c6ca2d..a762f015 100644 --- a/docs-site/index.md +++ b/docs-site/index.md @@ -205,7 +205,14 @@ verify -> outcomes.approval [:octicons-arrow-right-24: See all integration guides](integrations/index.md) -## What's New in v1.1 +## What's New in v1.2 + +- **Audit recovery is release-hardened** — `rampart serve` reconstructs the audit chain head from the latest valid JSONL event across log files, so absent, stale, or tampered anchors cannot reset the next `prev_hash`. +- **Hosted approval foundation** — Rampart can support host-owned approval flows without creating a second hidden Rampart approval queue, preserving a single user-facing approval owner. +- **Experimental Hermes policy gate correlation** — Hermes tool-call metadata now reaches Rampart audit records so policy-gate decisions can be traced back to the originating Hermes tool call. +- **Bundled plugin versions are coherent** — OpenClaw and experimental Hermes plugin manifests/runtime exports now report `1.2.0` alongside the Rampart release. + +### v1.1 - **Machine-readable diagnostics** — `rampart status --json`, `rampart doctor --json`, and `rampart inventory --json` expose structured runtime and integration state for automation. - **OpenClaw gateway v4 support** — the bundled plugin speaks the current gateway/status response contract while preserving native approval and audit ownership. diff --git a/docs-site/integrations/openclaw.md b/docs-site/integrations/openclaw.md index a8aaf834..bbb01206 100644 --- a/docs-site/integrations/openclaw.md +++ b/docs-site/integrations/openclaw.md @@ -157,7 +157,7 @@ Or check plugin status directly: ```bash openclaw plugins list -# rampart v1.1.1 active +# rampart v1.2.0 active ``` ## Troubleshooting diff --git a/docs-site/reference/threat-model.md b/docs-site/reference/threat-model.md index fe179c01..da6347b8 100644 --- a/docs-site/reference/threat-model.md +++ b/docs-site/reference/threat-model.md @@ -1,6 +1,6 @@ # Threat Model -> Last reviewed: 2026-05-26 | Applies to: v1.1.1 +> Last reviewed: 2026-05-28 | Applies to: v1.2.0 Rampart is a policy engine for AI agents — not a sandbox, not a hypervisor, not a full isolation boundary. This document describes what Rampart protects against, what it doesn't, and why. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 195c56a8..dd10feaa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -45,12 +45,12 @@ What's coming next for Rampart. Priorities shift based on feedback — [open an ## Current Focus -### `v1.1.0` -- Keep structured diagnostics boring and automation-friendly: `status --json`, `doctor --json`, and `inventory --json` should remain stable enough for scripts without replacing human-readable output. -- Treat current OpenClaw gateway protocol support and Codex native shell audit correlation as release-gated integrations, with live regression proof before tagging. -- Keep the release toolchain, plugin metadata, setup output, and docs aligned so users can answer "am I protected, how, and what version is active?" without reading source. +### `v1.2.0` +- Keep release-integrity claims backed by verifiable audit-chain behavior: startup recovery must continue from the latest valid JSONL event even when anchors are absent, stale, or tampered. +- Keep hosted approval work scoped to the correct ownership boundary: host agents own the visible approval and exact resume, while Rampart owns policy, deny enforcement, audit, and diagnostics. +- Keep bundled OpenClaw and experimental Hermes plugin metadata, setup output, and docs aligned so users can answer "am I protected, how, and what version is active?" without reading source. -### After 1.1 +### After 1.2 - Collect feedback from real OpenClaw, Claude Code, Cline, Codex, and MCP users. - Fix integration bugs without widening the public API unless the tradeoff is explicit. - Keep the support matrix and threat model honest as agent runtimes evolve. diff --git a/docs/index.html b/docs/index.html index 0b87de42..b986426d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -27,7 +27,7 @@ "url": "https://rampart.sh", "applicationCategory": "SecurityApplication", "operatingSystem": "Linux, macOS, Windows", - "softwareVersion": "1.1.1", + "softwareVersion": "1.2.0", "offers": { "@type": "Offer", "price": "0", @@ -1112,7 +1112,7 @@
-
v1.1.1 · release-ready
+
v1.2.0 · release-ready

Your agent has root.
That’s the problem.

Rampart sits in the execution path. Install it, run rampart quickstart, and it wires the right protection path for Claude Code, Codex, Cline, or OpenClaw before risky tool calls run.

diff --git a/docs/install b/docs/install index 5c51fd70..5559d0ec 100644 --- a/docs/install +++ b/docs/install @@ -7,8 +7,8 @@ # 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 -# RAMPART_INSTALL_DRY_RUN=1 sh install.sh --version v1.1.1 -# RAMPART_VERSION=v1.1.1 RAMPART_INSTALL_DIR=$HOME/.local/bin sh install.sh +# RAMPART_INSTALL_DRY_RUN=1 sh install.sh --version v1.2.0 +# RAMPART_VERSION=v1.2.0 RAMPART_INSTALL_DIR=$HOME/.local/bin sh install.sh set -e REPO="peg/rampart" diff --git a/docs/install.sh b/docs/install.sh index 5c51fd70..5559d0ec 100755 --- a/docs/install.sh +++ b/docs/install.sh @@ -7,8 +7,8 @@ # 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 -# RAMPART_INSTALL_DRY_RUN=1 sh install.sh --version v1.1.1 -# RAMPART_VERSION=v1.1.1 RAMPART_INSTALL_DIR=$HOME/.local/bin sh install.sh +# RAMPART_INSTALL_DRY_RUN=1 sh install.sh --version v1.2.0 +# RAMPART_VERSION=v1.2.0 RAMPART_INSTALL_DIR=$HOME/.local/bin sh install.sh set -e REPO="peg/rampart" diff --git a/install.sh b/install.sh index 5c51fd70..5559d0ec 100755 --- a/install.sh +++ b/install.sh @@ -7,8 +7,8 @@ # 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 -# RAMPART_INSTALL_DRY_RUN=1 sh install.sh --version v1.1.1 -# RAMPART_VERSION=v1.1.1 RAMPART_INSTALL_DIR=$HOME/.local/bin sh install.sh +# RAMPART_INSTALL_DRY_RUN=1 sh install.sh --version v1.2.0 +# RAMPART_VERSION=v1.2.0 RAMPART_INSTALL_DIR=$HOME/.local/bin sh install.sh set -e REPO="peg/rampart" diff --git a/internal/audit/jsonl.go b/internal/audit/jsonl.go index 8cc23a47..c09465b3 100644 --- a/internal/audit/jsonl.go +++ b/internal/audit/jsonl.go @@ -21,6 +21,7 @@ import ( "log/slog" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -28,56 +29,82 @@ import ( "github.com/oklog/ulid/v2" ) -// readLastLineHash reads the last non-empty line of a JSONL file and extracts -// its "hash" field. Returns the hash and true if successful. -func readLastLineHash(path string) (string, bool) { - f, err := os.Open(path) - if err != nil { - return "", false - } - defer f.Close() +type recoveredChainState struct { + eventCount int64 + lastHash string + lastFile string +} - var lastLine string - scanner := bufio.NewScanner(f) - for scanner.Scan() { - if line := scanner.Text(); line != "" { - lastLine = line +// recoverChainStateFromDir reconstructs the append position from the latest +// valid event in existing JSONL audit files. Chain-continuation headers are +// skipped. Anchors are checkpoints for verification, not the authority for the +// next event's prev_hash. +func recoverChainStateFromDir(dir string, logger *slog.Logger) recoveredChainState { + entries, err := os.ReadDir(dir) + if err != nil { + if logger != nil { + logger.Debug("audit: read audit dir during recovery", "error", err) } + return recoveredChainState{} } - if lastLine == "" { - return "", false - } - var partial struct { - Hash string `json:"hash"` - } - if err := json.Unmarshal([]byte(lastLine), &partial); err != nil { - return "", false - } - return partial.Hash, partial.Hash != "" -} -// countLinesInDir counts non-empty lines across all .jsonl files in dir -// using streaming IO to avoid loading entire files into memory. -func countLinesInDir(dir string) int64 { - var count int64 - entries, _ := os.ReadDir(dir) - for _, e := range entries { - if !strings.HasSuffix(e.Name(), ".jsonl") { + files := make([]string, 0) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { continue } - f, err := os.Open(filepath.Join(dir, e.Name())) + files = append(files, entry.Name()) + } + sort.Strings(files) + + var state recoveredChainState + for _, name := range files { + path := filepath.Join(dir, name) + file, err := os.Open(path) if err != nil { + if logger != nil { + logger.Debug("audit: open audit file during recovery", "file", name, "error", err) + } continue } - scanner := bufio.NewScanner(f) + + scanner := bufio.NewScanner(file) + lineNum := 0 for scanner.Scan() { - if len(scanner.Bytes()) > 0 { - count++ + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue } + + var event Event + if err := json.Unmarshal([]byte(line), &event); err != nil { + if logger != nil { + logger.Debug("audit: skip unparsable audit line during recovery", "file", name, "line", lineNum, "error", err) + } + continue + } + if event.ID == "" { + continue + } + ok, err := event.VerifyHash() + if err != nil || !ok { + if logger != nil { + logger.Debug("audit: skip invalid audit event during recovery", "file", name, "line", lineNum, "event_id", event.ID, "error", err) + } + continue + } + + state.eventCount++ + state.lastHash = event.Hash + state.lastFile = name + } + if err := scanner.Err(); err != nil && logger != nil { + logger.Debug("audit: scan audit file during recovery", "file", name, "error", err) } - _ = f.Close() + _ = file.Close() } - return count + return state } // JSONLSink is an append-only JSONL audit sink with hash chaining. @@ -126,44 +153,42 @@ func NewJSONLSink(dir string, opts ...SinkOption) (*JSONLSink, error) { logger: logger, } - // Recover state from anchor file if it exists. + // Recover append state from existing JSONL events. Anchors are not trusted as + // the sole chain head because they can be absent, stale, or tampered with. + recovered := recoverChainStateFromDir(dir, logger) + sink.eventCount = recovered.eventCount + sink.lastHash = recovered.lastHash + if sink.eventCount > 0 { + logger.Info("audit: recovered chain state from log files", + "event_count", sink.eventCount, + "hash", sink.lastHash, + "file", recovered.lastFile, + ) + } + + // Inspect the current anchor for diagnostics only. The next event always + // continues from the latest valid JSONL event recovered above. anchorPath := filepath.Join(dir, anchorFilename) - anchorTrusted := false if data, err := os.ReadFile(anchorPath); err == nil { var anchor ChainAnchor - if err := json.Unmarshal(data, &anchor); err == nil { - // Validate anchor: verify the hash matches the last line of the referenced log file. - if anchor.File != "" { - if lastHash, ok := readLastLineHash(filepath.Join(dir, anchor.File)); ok { - if lastHash == anchor.Hash { - anchorTrusted = true - } else { - logger.Debug("audit: anchor hash mismatch, falling back to line count", - "tamper_suspected", false, - "anchor_hash", anchor.Hash, - "file_hash", lastHash, - "file", anchor.File, - ) - } - } - } - if anchorTrusted { - sink.lastHash = anchor.Hash - sink.eventCount = anchor.EventCount - logger.Info("audit: recovered state from anchor", + if err := json.Unmarshal(data, &anchor); err == nil && anchor.EventID != "" { + if anchor.Hash == sink.lastHash && anchor.EventCount == sink.eventCount { + logger.Debug("audit: anchor matches recovered chain head", "event_count", anchor.EventCount, "hash", anchor.Hash, + "file", anchor.File, + ) + } else { + logger.Debug("audit: anchor is not current chain head; continuing from latest log event", + "anchor_event_count", anchor.EventCount, + "recovered_event_count", sink.eventCount, + "anchor_hash", anchor.Hash, + "recovered_hash", sink.lastHash, + "file", anchor.File, ) } } } - if !anchorTrusted { - // No anchor — count non-empty lines in existing log files to recover eventCount. - sink.eventCount = countLinesInDir(dir) - if sink.eventCount > 0 { - logger.Info("audit: recovered event count from log files", "event_count", sink.eventCount) - } - } if err := sink.openNewFileLocked(false, ""); err != nil { return nil, err diff --git a/internal/audit/jsonl_test.go b/internal/audit/jsonl_test.go index 9db06327..bfb93ce6 100644 --- a/internal/audit/jsonl_test.go +++ b/internal/audit/jsonl_test.go @@ -378,6 +378,76 @@ func TestJSONLSink_RecoverByLineCounting(t *testing.T) { defer sink2.Close() assert.EqualValues(t, 5, sink2.eventCount) + assert.NotEmpty(t, sink2.lastHash) +} + +func TestJSONLSink_RecoverWithoutAnchorContinuesHashChain(t *testing.T) { + dir := t.TempDir() + + sink, err := NewJSONLSink(dir, WithFsync(false), WithAnchorInterval(0)) + require.NoError(t, err) + for i := 0; i < 5; i++ { + require.NoError(t, sink.Write(sampleEvent("exec"))) + } + savedHash := sink.lastHash + require.NoError(t, sink.Close()) + + sink2, err := NewJSONLSink(dir, WithFsync(false), WithAnchorInterval(0), + WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))) + require.NoError(t, err) + defer sink2.Close() + + assert.EqualValues(t, 5, sink2.eventCount) + assert.Equal(t, savedHash, sink2.lastHash) + require.NoError(t, sink2.Write(sampleEvent("exec"))) + assertLastEventPrevHash(t, sink2.filePath(), savedHash) +} + +func TestJSONLSink_StaleAnchorDoesNotOverrideLatestEvent(t *testing.T) { + dir := t.TempDir() + + // Anchor interval 2 leaves a valid anchor on event 2 after event 3 is written. + sink, err := NewJSONLSink(dir, WithFsync(false), WithAnchorInterval(2)) + require.NoError(t, err) + for i := 0; i < 3; i++ { + require.NoError(t, sink.Write(sampleEvent("exec"))) + } + savedHash := sink.lastHash + require.NoError(t, sink.Close()) + + sink2, err := NewJSONLSink(dir, WithFsync(false), WithAnchorInterval(2), + WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))) + require.NoError(t, err) + defer sink2.Close() + + assert.EqualValues(t, 3, sink2.eventCount) + assert.Equal(t, savedHash, sink2.lastHash) + require.NoError(t, sink2.Write(sampleEvent("exec"))) + assertLastEventPrevHash(t, sink2.filePath(), savedHash) +} + +func TestJSONLSink_RecoverAcrossDayFileContinuesHashChain(t *testing.T) { + dir := t.TempDir() + + sink, err := NewJSONLSink(dir, WithFsync(false), WithAnchorInterval(0)) + require.NoError(t, err) + require.NoError(t, sink.Write(sampleEvent("exec"))) + savedHash := sink.lastHash + oldPath := sink.filePath() + require.NoError(t, sink.Close()) + + yesterdayName := time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") + ".jsonl" + require.NoError(t, os.Rename(oldPath, filepath.Join(dir, yesterdayName))) + + sink2, err := NewJSONLSink(dir, WithFsync(false), WithAnchorInterval(0), + WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))) + require.NoError(t, err) + defer sink2.Close() + + assert.EqualValues(t, 1, sink2.eventCount) + assert.Equal(t, savedHash, sink2.lastHash) + require.NoError(t, sink2.Write(sampleEvent("exec"))) + assertLastEventPrevHash(t, sink2.filePath(), savedHash) } func TestJSONLSink_TamperedAnchorFallsBack(t *testing.T) { @@ -389,6 +459,7 @@ func TestJSONLSink_TamperedAnchorFallsBack(t *testing.T) { for i := 0; i < 3; i++ { require.NoError(t, sink.Write(sampleEvent("exec"))) } + savedHash := sink.lastHash require.NoError(t, sink.Close()) // Tamper with the anchor — change the hash. @@ -407,10 +478,11 @@ func TestJSONLSink_TamperedAnchorFallsBack(t *testing.T) { require.NoError(t, err) defer sink2.Close() - // Should have recovered 3 events by line counting (not 2 from anchor). + // Should have recovered 3 events by scanning log events, not from the anchor. assert.EqualValues(t, 3, sink2.eventCount) - // lastHash should be empty since we didn't trust the anchor. - assert.Empty(t, sink2.lastHash) + assert.Equal(t, savedHash, sink2.lastHash) + require.NoError(t, sink2.Write(sampleEvent("exec"))) + assertLastEventPrevHash(t, sink2.filePath(), savedHash) } func TestJSONLSink_WriteErrorDoesNotUpdateHash(t *testing.T) { @@ -452,6 +524,26 @@ func sampleEvent(tool string) Event { } } +func assertLastEventPrevHash(t *testing.T, path, wantPrevHash string) { + t.Helper() + lines := readJSONLLines(t, path) + require.NotEmpty(t, lines) + + for i := len(lines) - 1; i >= 0; i-- { + var event Event + require.NoError(t, json.Unmarshal([]byte(lines[i]), &event)) + if event.ID == "" { + continue + } + assert.Equal(t, wantPrevHash, event.PrevHash) + ok, err := event.VerifyHash() + require.NoError(t, err) + assert.True(t, ok) + return + } + t.Fatal("no audit event found") +} + func readJSONLLines(t *testing.T, path string) []string { t.Helper() diff --git a/internal/plugin/hermes/__init__.py b/internal/plugin/hermes/__init__.py index 06d02791..3da52ab4 100644 --- a/internal/plugin/hermes/__init__.py +++ b/internal/plugin/hermes/__init__.py @@ -29,7 +29,7 @@ from typing import Any, Callable, Mapping from urllib.parse import quote -VERSION = "0.1.1" +VERSION = "1.2.0" DEFAULT_SERVE_URL = "http://127.0.0.1:9090" DEFAULT_TIMEOUT_MS = 3000 diff --git a/internal/plugin/hermes/embed_test.go b/internal/plugin/hermes/embed_test.go index 6d10c551..18a78e6b 100644 --- a/internal/plugin/hermes/embed_test.go +++ b/internal/plugin/hermes/embed_test.go @@ -11,7 +11,7 @@ import ( ) func TestVersionReadsManifest(t *testing.T) { - if got, want := Version(), "0.1.1"; got != want { + if got, want := Version(), "1.2.0"; got != want { t.Fatalf("Version() = %q, want %q", got, want) } } diff --git a/internal/plugin/hermes/plugin.yaml b/internal/plugin/hermes/plugin.yaml index b2aaca72..279166f5 100644 --- a/internal/plugin/hermes/plugin.yaml +++ b/internal/plugin/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: rampart -version: 0.1.1 +version: 1.2.0 description: Experimental Rampart policy gate for Hermes Agent pre_tool_call hooks author: peg provides_hooks: diff --git a/internal/plugin/openclaw/index.js b/internal/plugin/openclaw/index.js index 44cbaac9..89f4fb18 100644 --- a/internal/plugin/openclaw/index.js +++ b/internal/plugin/openclaw/index.js @@ -5,7 +5,7 @@ * Replaces brittle dist-file patching with the official OpenClaw plugin API. * * @see https://github.com/peg/rampart - * @version 1.1.1 + * @version 1.2.0 */ import { readFile } from "fs/promises"; @@ -259,7 +259,7 @@ async function auditLog(toolName, params, ctx, outcome, config) { export const id = "rampart"; export const name = "Rampart"; export const description = "AI agent firewall — YAML policy-as-code for every tool call"; -export const version = "1.1.1"; +export const version = "1.2.0"; // OpenClaw runs higher-priority before_tool_call hooks first. Rampart should // act as the final normal plugin gate so it evaluates the params that will diff --git a/internal/plugin/openclaw/openclaw.plugin.json b/internal/plugin/openclaw/openclaw.plugin.json index 1492837b..983b487d 100644 --- a/internal/plugin/openclaw/openclaw.plugin.json +++ b/internal/plugin/openclaw/openclaw.plugin.json @@ -8,7 +8,7 @@ "hook" ] }, - "version": "1.1.1", + "version": "1.2.0", "author": "peg", "homepage": "https://rampart.sh", "repository": "https://github.com/peg/rampart", diff --git a/internal/plugin/openclaw/package.json b/internal/plugin/openclaw/package.json index 96d1b55d..e85c74d4 100644 --- a/internal/plugin/openclaw/package.json +++ b/internal/plugin/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "rampart", - "version": "1.1.1", + "version": "1.2.0", "description": "Rampart AI agent firewall — OpenClaw native plugin (before_tool_call hook)", "type": "module", "main": "index.js", diff --git a/policies/openclaw.yaml b/policies/openclaw.yaml index 14178152..bf1904cf 100644 --- a/policies/openclaw.yaml +++ b/policies/openclaw.yaml @@ -1,4 +1,4 @@ -# rampart-policy-version: 1.0.0 +# rampart-policy-version: 1.2.0 # Rampart built-in profile: openclaw # Use with: rampart init --profile openclaw # diff --git a/scripts/install.sh b/scripts/install.sh index 5c51fd70..5559d0ec 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,8 +7,8 @@ # 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 -# RAMPART_INSTALL_DRY_RUN=1 sh install.sh --version v1.1.1 -# RAMPART_VERSION=v1.1.1 RAMPART_INSTALL_DIR=$HOME/.local/bin sh install.sh +# RAMPART_INSTALL_DRY_RUN=1 sh install.sh --version v1.2.0 +# RAMPART_VERSION=v1.2.0 RAMPART_INSTALL_DIR=$HOME/.local/bin sh install.sh set -e REPO="peg/rampart" From 0077cbfbbd15b9eed61eb4e4858b18d65e270653 Mon Sep 17 00:00:00 2001 From: "clap [bot]" Date: Thu, 28 May 2026 09:17:42 +0000 Subject: [PATCH 5/9] docs: tighten threat model boundaries --- docs-site/reference/threat-model.md | 67 +++++++++++++++-------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/docs-site/reference/threat-model.md b/docs-site/reference/threat-model.md index da6347b8..bd631c7e 100644 --- a/docs-site/reference/threat-model.md +++ b/docs-site/reference/threat-model.md @@ -2,11 +2,11 @@ > Last reviewed: 2026-05-28 | Applies to: v1.2.0 -Rampart is a policy engine for AI agents — not a sandbox, not a hypervisor, not a full isolation boundary. This document describes what Rampart protects against, what it doesn't, and why. +Rampart evaluates AI-agent tool calls against policy before an action proceeds. It is a policy enforcement layer, not a replacement for OS sandboxing, hypervisors, or workload isolation. This document defines the threats Rampart is designed to reduce and the boundaries operators should pair it with. ## What Rampart Is -A firewall for AI agent tool calls. It evaluates agent tool calls — shell commands, file operations, and fetch requests — against YAML policies and makes allow/deny/log decisions in microseconds. Rampart sees what the agent framework sends it (tool call metadata), not raw syscalls or network traffic. It's designed to catch the 95%+ case: an AI agent that hallucinated a dangerous command or got manipulated by a prompt injection. +A firewall for AI agent tool calls. It evaluates shell commands, file operations, fetch requests, and other framework-reported actions against YAML policies, then returns allow, deny, or watch decisions in microseconds. Rampart sees the metadata an agent framework sends it, not raw syscalls or network packets. It is designed for the common agent-risk case: a dangerous command caused by hallucination, prompt injection, or operator context mistakes. ## Primary Threat: Misbehaving AI Agents @@ -17,7 +17,7 @@ Rampart's target threat is an AI agent that: - **Made a well-intentioned mistake** (wrong environment, wrong file, wrong server) - **Escalated beyond its intended scope** (sub-agent spawning unrestricted tool calls) -These agents aren't adversarial — they're confused, manipulated, or wrong. Rampart catches them reliably. +These agents are not adversarial. They are confused, manipulated, or wrong. Rampart is designed to catch these cases when the relevant integration path sends the tool call to Rampart. ## Not the Target: Adversarial Human Attackers @@ -51,25 +51,25 @@ Policy files are the security boundary. If an attacker can modify policy files, ### 1. Interpreter Bypass -Rampart evaluates the command string passed to the shell. This applies to **all integration methods** — native hooks (Claude Code, Cline), wrap mode, LD_PRELOAD, and the HTTP API all see the same command string. If an agent runs `python3 script.py`, Rampart sees and evaluates `python3 script.py` — but cannot inspect what `script.py` does internally. +Rampart evaluates the command string passed to the shell. This applies to **all integration methods**. Native hooks (Claude Code, Cline), wrap mode, LD_PRELOAD, and the HTTP API all see the same command string. If an agent runs `python3 script.py`, Rampart sees and evaluates `python3 script.py`, but cannot inspect what `script.py` does internally. **Mitigations:** -- **LD_PRELOAD cascade** (v0.1.9+): `rampart preload` and `rampart wrap` intercept child processes spawned by allowed commands. `python3 script.py` calling `os.system("rm -rf /")` is caught — the subprocess goes through Rampart's policy engine. +- **LD_PRELOAD cascade** (v0.1.9+): `rampart preload` and `rampart wrap` intercept child processes spawned by allowed commands. If `python3 script.py` calls `os.system("rm -rf /")`, the subprocess goes through Rampart's policy engine. - **Interpreter one-liner blocking** (v0.6.9+): Patterns like `python3 -c`, `node -e`, `ruby -e`, `perl -e` with dangerous system calls are blocked by default in standard/paranoid policies. - The optional [rampart-verify](https://github.com/peg/rampart-verify) sidecar uses LLM classification to assess intent of ambiguous commands via `action: webhook`. **Remaining surface:** LD_PRELOAD cascade only applies to wrap/preload modes, not native hooks (Claude Code, Cline). Programs that use native file I/O without shelling out, or setuid binaries that drop LD_PRELOAD, are not covered. -**Multi-step sequences:** With file tool coverage enabled (native hooks, OpenClaw native plugin, or legacy `--patch-tools`), write-then-execute sequences are evaluated at both steps independently — the write is checked against file policies and the exec against command policies. +**Multi-step sequences:** With file tool coverage enabled (native hooks, OpenClaw native plugin, or legacy `--patch-tools`), write-then-execute sequences are evaluated at both steps independently. The write is checked against file policies, and the exec is checked against command policies. ### 2. Audit Log Rewrite -The hash-chained audit trail detects **partial tampering** — editing, inserting, or deleting individual records breaks the chain. However, a complete rewrite from scratch with a new valid chain is not detectable from the log file alone. +The hash-chained audit trail detects **partial tampering**. Editing, inserting, or deleting individual records breaks the chain. However, a complete rewrite from scratch with a new valid chain is not detectable from the log file alone. **Mitigations:** - Run `rampart serve` as a [separate user](../deployment/production-checklist.md) so the agent can't access audit files - Enable SIEM export (`--syslog` or `--cef`) to send events to an external immutable system -- Webhook notifications send real-time alerts to Discord/Slack — a separate record +- Webhook notifications send real-time alerts to Discord/Slack as a separate record - External chain anchors every 100 events provide additional integrity checkpoints **For compliance environments:** Pair with external immutable logging (CloudTrail, Wazuh, etc.) for an independent trust anchor. See the [SIEM integration guide](../features/siem-integration.md). @@ -80,7 +80,7 @@ The `rampart wrap` shim needs a bearer token to authenticate with `rampart serve **Mitigations:** - Run `rampart serve` as a separate user and store the token in that user's home directory with restricted permissions -- Use native hooks (`rampart setup claude-code`) instead of wrap mode — hooks don't require a token file +- Use native hooks (`rampart setup claude-code`) instead of wrap mode because hooks do not require a token file **Resolution:** Use [user separation](../deployment/production-checklist.md) for production deployments, or prefer native hooks which don't require a token file. @@ -95,7 +95,7 @@ An agent could encode commands to bypass pattern matching: - Rampart decodes base64 commands before pattern matching - Leading shell comments and ANSI escape sequences are stripped - Null bytes and control characters are removed -- Subcommand extraction: `$(cmd)`, backticks, `eval 'cmd'` — inner commands are matched independently +- Subcommand extraction: `$(cmd)`, backticks, and `eval 'cmd'` forms are matched independently - Common obfuscation patterns (`base64 *`, `eval *`, `xxd -r | bash`) trigger deny rules in standard policy - The semantic verification sidecar classifies intent regardless of encoding @@ -103,25 +103,25 @@ An agent could encode commands to bypass pattern matching: ### 5. Framework-Specific Patching -Older OpenClaw builds did not expose a native file-tool hook, so Rampart added `--patch-tools` as a compatibility path that modifies framework source files before read/write/edit operations. These patches don't survive framework upgrades — they modify files in `node_modules` that get replaced on update. +Older OpenClaw builds did not expose a native file-tool hook, so Rampart added `--patch-tools` as a compatibility path that modifies framework source files before read/write/edit operations. These patches do not survive framework upgrades because they modify files in `node_modules` that get replaced on update. **Mitigations:** -- Prefer the native OpenClaw plugin on current builds (`rampart setup openclaw`) — it covers tool calls without dist patching +- Prefer the native OpenClaw plugin on current builds (`rampart setup openclaw`), which covers tool calls without dist patching - `rampart setup openclaw --patch-tools` must be re-run immediately after OpenClaw upgrades to restore protection on legacy setups -- Native hook integrations (Claude Code, Cline) don't have this limitation — they use the framework's own hook system +- Native hook integrations (Claude Code, Cline) do not have this limitation because they use the framework's own hook system **Security implications:** - **Timing window:** Between framework upgrade and re-patch, file tools bypass all policies (exec shim remains active) - **Silent degradation:** If the target code changes in a new version, patches fail to apply and file tools fail-open without warning. The patch script exits with an error, but if run unattended this could go unnoticed. -**Trade-off:** Monkey-patching is fragile but functional. It closes a real security gap today while proper upstream hook support is developed. The patches fail-open — if the patched code changes in an upgrade, the worst case is that file tools bypass Rampart (reverting to the pre-patch state), not that they break. +**Trade-off:** Monkey-patching is fragile and should be treated as a legacy compatibility path. If the patched code changes during an upgrade, file tools may bypass Rampart until re-patched. Prefer native plugin or native hook integrations where available. ### 6. Degraded-Mode Behavior Rampart does **not** behave identically across every integration when policy evaluation becomes unavailable. That difference is a real security boundary and has to be understood clearly. **Current behavior:** -- `rampart wrap` and `rampart preload` default to **fail-open** — if `rampart serve` is unreachable, commands continue without policy checks unless you configure fail-closed behavior. +- `rampart wrap` and `rampart preload` default to **fail-open**. If `rampart serve` is unreachable, commands continue without policy checks unless you configure fail-closed behavior. - The native OpenClaw plugin is stricter: sensitive tools such as `exec`, `write`, `edit`, `browser`, and `message` block when `rampart serve` is unavailable, while explicitly configured lower-risk tools (`read`, `web_fetch`, `web_search`, `image` by default) remain fail-open. - Native hook integrations (Claude Code, Cline) evaluate policies locally in-process, so they do not depend on `rampart serve` for the core allow/deny path. @@ -180,7 +180,7 @@ Project-local `.rampart/policy.yaml` files are loaded automatically when present ### 11. Community Policy Supply Chain -`rampart policy fetch` downloads policies from the registry with SHA-256 verification. However, the registry itself is hosted in the main repo — a compromise of the repository could introduce malicious policies. +`rampart policy fetch` downloads policies from the registry with SHA-256 verification. However, the registry itself is hosted in the main repo, so a repository compromise could introduce malicious policies. **Mitigations:** - SHA-256 verification prevents modification after registry publication @@ -205,11 +205,11 @@ Project-local `.rampart/policy.yaml` files are loaded automatically when present v0.4.4 added 17 macOS-specific built-in policies to the standard and paranoid profiles. These cover: -- **Keychain access** — blocks unauthorized reads from the macOS Keychain (`security` tool abuse) -- **Gatekeeper bypass** — blocks attempts to disable or circumvent Gatekeeper (`spctl`, `xattr -d com.apple.quarantine`) -- **Persistence mechanisms** — blocks writes to `~/Library/LaunchAgents/`, `~/Library/LaunchDaemons/`, and login items -- **User management** — blocks `dscl` and `sysadminctl` commands that create or elevate user accounts -- **AppleScript shell execution** — blocks `osascript -e "do shell script …"` patterns used to run commands via AppleScript +- **Keychain access:** blocks unauthorized reads from the macOS Keychain (`security` tool abuse) +- **Gatekeeper bypass:** blocks attempts to disable or circumvent Gatekeeper (`spctl`, `xattr -d com.apple.quarantine`) +- **Persistence mechanisms:** blocks writes to `~/Library/LaunchAgents/`, `~/Library/LaunchDaemons/`, and login items +- **User management:** blocks `dscl` and `sysadminctl` commands that create or elevate user accounts +- **AppleScript shell execution:** blocks `osascript -e "do shell script …"` patterns used to run commands via AppleScript These policies are active automatically when using the standard or paranoid profile on macOS. @@ -217,11 +217,11 @@ These policies are active automatically when using the standard or paranoid prof v0.6.6 added Windows policy parity. Key differences from Linux/macOS: -- **No LD_PRELOAD** — `rampart preload` is not available. Use native hooks or wrap mode instead. -- **No POSIX file permissions** — `chmod 0600` is not enforced by the OS. Token files and signing keys are created with default permissions; use Windows ACLs for hardening. -- **Binary upgrade** — Windows forbids overwriting a running executable. `rampart upgrade` renames the current binary to `.rampart.exe.old` first, then installs the new one. -- **Path separators** — Rampart normalizes backslashes to forward slashes internally for consistent policy matching. -- **Service management** — `rampart serve install` creates a Windows service (not systemd/launchd). Auto-restart is configured by default. +- **No LD_PRELOAD:** `rampart preload` is not available. Use native hooks or wrap mode instead. +- **No POSIX file permissions:** `chmod 0600` is not enforced by the OS. Token files and signing keys are created with default permissions; use Windows ACLs for hardening. +- **Binary upgrade:** Windows forbids overwriting a running executable. `rampart upgrade` renames the current binary to `.rampart.exe.old` first, then installs the new one. +- **Path separators:** Rampart normalizes backslashes to forward slashes internally for consistent policy matching. +- **Service management:** `rampart serve install` creates a Windows service (not systemd/launchd). Auto-restart is configured by default. ## Deployment Recommendations @@ -231,9 +231,9 @@ v0.6.6 added Windows policy parity. Key differences from Linux/macOS: | Separate user | ❌ No | ❌ No | Production, unsupervised agents | | Separate user + SIEM | ❌ No | ❌ No | Enterprise, compliance | -**Prerequisite:** The agent must run as a non-root user. If the agent runs as root, user separation provides no protection — root can read and modify all files regardless of ownership. +**Prerequisite:** The agent must run as a non-root user. If the agent runs as root, user separation provides no protection because root can read and modify all files regardless of ownership. -**Sudo caveat:** Many real-world deployments grant the agent user `sudo` access for system administration tasks. An agent with unrestricted `sudo` (e.g., `NOPASSWD: ALL`) can bypass user separation by running `sudo cat /etc/rampart/policy.yaml` or `sudo rm -rf /var/lib/rampart/audit/`. Rampart still catches the common case — a hallucinating or prompt-injected agent won't think to `sudo` around a deny rule — but it's not a hard boundary. +**Sudo caveat:** Many real-world deployments grant the agent user `sudo` access for system administration tasks. An agent with unrestricted `sudo` (for example, `NOPASSWD: ALL`) can bypass user separation by running `sudo cat /etc/rampart/policy.yaml` or `sudo rm -rf /var/lib/rampart/audit/`. Rampart still catches the common case where a hallucinating or prompt-injected agent does not intentionally bypass a deny rule, but sudo access means user separation is not a hard boundary. **Best practice:** Restrict sudo to the specific commands your agent needs (e.g., `apt`, `systemctl`, `k3s`) rather than granting blanket access. This limits the blast radius regardless of Rampart. @@ -260,11 +260,12 @@ The remaining risk is narrower but still real: in same-user deployments, any int v0.7.4 introduced temporal allows (`--for`, `--once`). Expired rules are **skipped during evaluation** but remain in the policy YAML until manually removed. **Security implications:** -- Expired rules exist in the YAML but are inert — the engine checks `expires_at` before matching +- Expired rules exist in the YAML but are inert because the engine checks `expires_at` before matching - `--once` rules are now consumed after their first successful match and removed from the backing policy file by the proxy layer - That removal is operationally best-effort rather than transactional: a crash at the wrong moment could leave a consumed `once` rule behind until cleanup or the next evaluation path removes it -- Automatic cleanup of expired rules is still not universal — use `rampart rules remove` or explicit cleanup flows to keep policy files tidy +- Expired-rule cleanup is explicit. Use `rampart rules remove` or another reviewed cleanup flow to keep policy files tidy - Clock skew: expiry is evaluated against the system clock. If the system clock is set backwards, an expired rule could become active again. Use NTP. + ## Self-Modification Protection Rampart protects its own configuration from agent tampering through two layers: @@ -272,15 +273,15 @@ Rampart protects its own configuration from agent tampering through two layers: 1. **Exec-level:** The standard policy blocks `rampart allow`, `rampart block`, `rampart init`, and shell redirects to `.rampart/` directories. This prevents agents from running CLI commands that modify policy. 2. **Write/Edit-level:** The standard policy blocks write and edit tool calls targeting `**/.rampart/**`. This prevents agents from directly overwriting policy files, config, or audit logs via file tools. -Both layers are active by default in the `standard` and `paranoid` profiles. The `yolo` profile disables these protections — it's named that way for a reason. +Both layers are active by default in the `standard` and `paranoid` profiles. The `yolo` profile disables these protections by design. **Remaining surface:** An agent with exec access could modify Rampart's binary on disk (if file permissions allow), or kill the `rampart serve` process (triggering fail-open). User separation mitigates both: run `rampart serve` as a different user than the agent. ## Philosophy -Rampart is a **seatbelt, not a roll cage**. It catches the vast majority of dangerous situations an AI agent will encounter — accidental or manipulated. It doesn't claim to stop every possible attack vector, and we're honest about what falls outside its scope. +Rampart is a **seatbelt, not a roll cage**. It is designed for dangerous agent actions that are accidental, manipulated, or contextually wrong. It does not claim to stop every possible attack vector, and this document keeps those boundaries explicit. -If you need full isolation, use a sandbox (container, VM, or a tool like [nono](https://github.com/nicholasgasior/nono)). Rampart and sandboxes are complementary — use both for defense in depth. +If you need full isolation, use a sandbox, container, VM, or a tool like [nono](https://github.com/nicholasgasior/nono). Rampart and sandboxes are complementary layers for defense in depth. --- From 4e386a4ae9150b2d7be47e63ed0078d8fa541b06 Mon Sep 17 00:00:00 2001 From: "clap [bot]" Date: Fri, 29 May 2026 18:18:01 +0000 Subject: [PATCH 6/9] chore: add integration compatibility gates --- .github/workflows/upstream-compat.yml | 46 +++ cmd/rampart/cli/doctor.go | 299 +++++++++++++++++ cmd/rampart/cli/doctor_test.go | 128 ++++++++ .../release-compatibility-gate.md | 73 +++++ docs-site/getting-started/support-matrix.md | 5 +- docs-site/integrations/hermes.md | 12 +- docs-site/integrations/openclaw.md | 8 +- mkdocs.yml | 1 + scripts/compat-hermes-latest.py | 308 ++++++++++++++++++ scripts/compat-openclaw-latest.mjs | 126 +++++++ 10 files changed, 1002 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/upstream-compat.yml create mode 100644 docs-site/getting-started/release-compatibility-gate.md create mode 100644 scripts/compat-hermes-latest.py create mode 100644 scripts/compat-openclaw-latest.mjs diff --git a/.github/workflows/upstream-compat.yml b/.github/workflows/upstream-compat.yml new file mode 100644 index 00000000..f07e0de0 --- /dev/null +++ b/.github/workflows/upstream-compat.yml @@ -0,0 +1,46 @@ +name: Upstream Compatibility + +on: + workflow_dispatch: + schedule: + - cron: '17 9 * * 1' + +permissions: + contents: read + +jobs: + hermes-latest: + name: Hermes latest isolated plugin gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25.10' + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Validate against latest Hermes Agent + run: python scripts/compat-hermes-latest.py + + openclaw-latest: + name: OpenClaw latest isolated plugin gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: '22' + + - name: Install latest OpenClaw + run: npm install -g openclaw@latest + + - name: Validate against latest OpenClaw + run: node scripts/compat-openclaw-latest.mjs diff --git a/cmd/rampart/cli/doctor.go b/cmd/rampart/cli/doctor.go index 658de6eb..7b4d49d2 100644 --- a/cmd/rampart/cli/doctor.go +++ b/cmd/rampart/cli/doctor.go @@ -34,6 +34,7 @@ import ( "github.com/peg/rampart/internal/engine" ochardening "github.com/peg/rampart/internal/openclaw/hardening" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) // defaultServePort is the default port for rampart serve. @@ -323,6 +324,12 @@ func runDoctor(w io.Writer, jsonOut bool) error { warnings += n } + // 17b. Hermes experimental plugin health. This is scoped separately from + // OpenClaw so optional Hermes gaps do not obscure OpenClaw readiness. + if n := doctorHermesIntegration(emit, serveURL, token); n > 0 { + warnings += n + } + // 18. Proactive policy suggestions (informational only) if detectResult, detectErr := detect.Environment(); detectErr == nil { client := newPolicyRegistryClient() @@ -2023,6 +2030,298 @@ func doctorOpenClawAskMode(emit emitFn) (warnings int) { } } +type hermesDoctorConfig struct { + Plugins struct { + Enabled []string `yaml:"enabled"` + Disabled []string `yaml:"disabled"` + Entries map[string]struct { + Config map[string]any `yaml:"config"` + } `yaml:"entries"` + } `yaml:"plugins"` +} + +type hermesPluginManifest struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + ProvidesHooks []string `yaml:"provides_hooks"` +} + +func doctorHermesIntegration(emit emitFn, serveURL, token string) (warnings int) { + home, err := os.UserHomeDir() + if err != nil { + return 0 + } + hermesHome := hermesHomeDir(home) + pluginDir := filepath.Join(hermesHome, "plugins", "rampart") + pluginInstalled := hermesPluginFilesInstalled(pluginDir) + + hermesBin, hermesErr := exec.LookPath("hermes") + hermesInstalled := hermesErr == nil + if !hermesInstalled && !pluginInstalled { + return 0 + } + + if hermesInstalled { + emit("Hermes Agent", "ok", hermesVersionSummary(hermesBin)) + } else { + emit("Hermes Agent", "warn", "Rampart Hermes plugin files are present, but the hermes binary was not found in PATH"+hintSep+ + "Install Hermes Agent or remove the plugin with: rampart setup hermes --remove") + warnings++ + } + + if !pluginInstalled { + emit("Hermes plugin", "warn", "Hermes is installed, but Rampart's experimental plugin is not installed"+hintSep+ + "rampart setup hermes --enable") + return warnings + 1 + } + + manifest, manifestErr := readHermesPluginManifest(pluginDir) + if manifestErr != nil { + emit("Hermes plugin", "warn", fmt.Sprintf("installed, but failed to parse plugin.yaml: %v", manifestErr)+hintSep+ + "Reinstall the plugin: rampart setup hermes") + warnings++ + } else { + if manifest.Name != "" && manifest.Name != "rampart" { + emit("Hermes plugin", "warn", fmt.Sprintf("installed manifest name is %q, expected rampart", manifest.Name)+hintSep+ + "Reinstall the plugin: rampart setup hermes") + warnings++ + } + if !containsString(manifest.ProvidesHooks, "pre_tool_call") { + emit("Hermes plugin", "warn", "installed manifest does not declare pre_tool_call hook support"+hintSep+ + "Reinstall the plugin from a current Rampart build: rampart setup hermes") + warnings++ + } + if manifest.Version != "" && !pluginVersionMatchesBuildVersion(manifest.Version, build.Version) { + emit("Hermes plugin", "warn", fmt.Sprintf("installed manifest version %s does not match rampart binary %s", manifest.Version, build.Version)+hintSep+ + "Rerun `rampart setup hermes` from the same Rampart build, then restart long-running Hermes gateways") + warnings++ + } else { + detail := "installed (experimental pre_tool_call policy gate)" + if manifest.Version != "" { + detail = fmt.Sprintf("installed (v%s, experimental pre_tool_call policy gate)", manifest.Version) + } + emit("Hermes plugin", "ok", detail) + } + } + + cfg, configPath, configErr := readHermesDoctorConfig(hermesHome) + pluginEnabled := false + pluginConfig := map[string]any(nil) + if configErr != nil { + emit("Hermes plugin enabled", "warn", fmt.Sprintf("could not verify plugins.enabled in %s: %v", configPath, configErr)+hintSep+ + "hermes plugins enable rampart") + warnings++ + } else { + if containsString(cfg.Plugins.Disabled, "rampart") { + emit("Hermes plugin enabled", "warn", "plugins.disabled includes rampart, so Hermes will not load the policy hook"+hintSep+ + "hermes plugins enable rampart") + warnings++ + } else if !containsString(cfg.Plugins.Enabled, "rampart") { + emit("Hermes plugin enabled", "warn", "plugin files are installed, but rampart is not listed in plugins.enabled"+hintSep+ + "hermes plugins enable rampart") + warnings++ + } else { + pluginEnabled = true + emit("Hermes plugin enabled", "ok", "rampart listed in plugins.enabled; restart long-running gateways after changes") + } + if cfg.Plugins.Entries != nil { + if entry, ok := cfg.Plugins.Entries["rampart"]; ok { + pluginConfig = entry.Config + } + } + } + + endpointMode := strings.ToLower(strings.TrimSpace(hermesConfigString(pluginConfig, "preflight", "endpoint_mode", "endpointMode"))) + if endpointMode == "" { + endpointMode = "preflight" + } + if endpointMode == "tool" { + emit("Hermes policy mode", "warn", "endpoint_mode=tool can create Rampart-native pending approvals that Hermes cannot resume"+hintSep+ + "Use endpoint_mode: preflight unless you are explicitly testing raw /v1/tool semantics") + warnings++ + } else if endpointMode != "preflight" { + emit("Hermes policy mode", "warn", fmt.Sprintf("unknown endpoint_mode %q; plugin will fall back to preflight", endpointMode)+hintSep+ + "Set endpoint_mode: preflight") + warnings++ + } else { + emit("Hermes policy mode", "ok", "preflight mode; ask decisions block until Hermes exposes plugin approval/resume") + } + + failOpenTools := hermesFailOpenTools(pluginConfig) + if risky := riskyHermesFailOpenTools(failOpenTools); len(risky) > 0 { + emit("Hermes degraded mode", "warn", fmt.Sprintf("fail_open_tools includes mutating/high-risk tools: %s", strings.Join(risky, ", "))+hintSep+ + "Limit fail_open_tools to explicit read-only tools or set it to [] for fail-closed behavior") + warnings++ + } else { + detail := "mutating/high-risk tools fail closed when Rampart is unavailable" + if len(failOpenTools) > 0 { + detail += fmt.Sprintf("; configured fail-open tools: %s", strings.Join(failOpenTools, ", ")) + } + emit("Hermes degraded mode", "ok", detail) + } + + if pluginEnabled { + if serveURL == "" { + emit("Hermes readiness", "warn", "plugin enabled, but rampart serve is unreachable; mutating/high-risk Hermes tools will fail closed"+hintSep+ + "rampart serve --background") + warnings++ + } else if strings.TrimSpace(token) == "" { + emit("Hermes readiness", "warn", "plugin enabled and serve reachable, but no Rampart token was found for authenticated policy checks"+hintSep+ + "rampart serve --background") + warnings++ + } else { + emit("Hermes readiness", "ok", fmt.Sprintf("experimental policy gate configured; serve reachable at %s; token present (redacted)", serveURL)) + } + } + + emit("Hermes support tier", "info", "experimental policy gate; full approval/resume support requires Hermes-owned approval APIs and E2E validation") + return warnings +} + +func hermesHomeDir(home string) string { + if envHome := strings.TrimSpace(os.Getenv("HERMES_HOME")); envHome != "" { + expanded := os.ExpandEnv(envHome) + if strings.HasPrefix(expanded, "~"+string(os.PathSeparator)) { + expanded = filepath.Join(home, strings.TrimPrefix(expanded, "~"+string(os.PathSeparator))) + } + return filepath.Clean(expanded) + } + return filepath.Join(home, ".hermes") +} + +func hermesPluginFilesInstalled(pluginDir string) bool { + if _, err := os.Stat(filepath.Join(pluginDir, "plugin.yaml")); err != nil { + return false + } + if _, err := os.Stat(filepath.Join(pluginDir, "__init__.py")); err != nil { + return false + } + return true +} + +func readHermesPluginManifest(pluginDir string) (hermesPluginManifest, error) { + var manifest hermesPluginManifest + data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.yaml")) + if err != nil { + return manifest, err + } + if err := yaml.Unmarshal(data, &manifest); err != nil { + return manifest, err + } + return manifest, nil +} + +func readHermesDoctorConfig(hermesHome string) (hermesDoctorConfig, string, error) { + var cfg hermesDoctorConfig + path := filepath.Join(hermesHome, "config.yaml") + data, err := os.ReadFile(path) + if err != nil { + return cfg, path, err + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cfg, path, err + } + return cfg, path, nil +} + +func hermesVersionSummary(bin string) string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, bin, "--version").CombinedOutput() + if err != nil || strings.TrimSpace(string(out)) == "" { + return "installed (version unavailable)" + } + line := strings.TrimSpace(strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n")[0]) + if len(line) > 160 { + line = line[:160] + } + return fmt.Sprintf("installed (%s)", line) +} + +func hermesConfigString(config map[string]any, defaultValue string, keys ...string) string { + if config == nil { + return defaultValue + } + for _, key := range keys { + if value, ok := config[key]; ok { + if s, ok := value.(string); ok { + return s + } + } + } + return defaultValue +} + +func hermesFailOpenTools(config map[string]any) []string { + defaultTools := []string{"read_file", "search_files", "browser_snapshot", "browser_get_images", "browser_vision", "vision_analyze"} + if config == nil { + return defaultTools + } + var raw any + for _, key := range []string{"fail_open_tools", "failOpenTools"} { + if value, ok := config[key]; ok { + raw = value + break + } + } + if raw == nil { + return defaultTools + } + var tools []string + switch v := raw.(type) { + case []string: + tools = append(tools, v...) + case []any: + for _, item := range v { + tools = append(tools, fmt.Sprint(item)) + } + case string: + tools = strings.Split(v, ",") + default: + return defaultTools + } + return normalizedStringList(tools) +} + +func riskyHermesFailOpenTools(tools []string) []string { + var risky []string + for _, tool := range tools { + switch strings.ToLower(strings.TrimSpace(tool)) { + case "terminal", "execute_code", "write_file", "patch", "process", "cronjob", "send_message", "text_to_speech", "memory", "todo", + "browser_back", "browser_cdp", "browser_click", "browser_console", "browser_dialog", "browser_navigate", "browser_press", "browser_scroll", "browser_type": + risky = append(risky, tool) + } + } + return normalizedStringList(risky) +} + +func normalizedStringList(values []string) []string { + seen := map[string]bool{} + var out []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + sort.Strings(out) + return out +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + func relHome(path, home string) string { if rel, err := filepath.Rel(home, path); err == nil { return rel diff --git a/cmd/rampart/cli/doctor_test.go b/cmd/rampart/cli/doctor_test.go index 7d8f9c95..8f8a9e97 100644 --- a/cmd/rampart/cli/doctor_test.go +++ b/cmd/rampart/cli/doctor_test.go @@ -855,6 +855,134 @@ if (!params.decision) { } } +func TestDoctorHermesIntegrationSkipsWhenHermesAbsent(t *testing.T) { + home := t.TempDir() + testSetHome(t, home) + t.Setenv("HERMES_HOME", filepath.Join(home, "hermes")) + t.Setenv("PATH", filepath.Join(home, "empty-bin")) + + var results []checkResult + warnings := doctorHermesIntegration(func(name, status, msg string) { + results = append(results, checkResult{Name: name, Status: status, Message: msg}) + }, "", "") + if warnings != 0 || len(results) != 0 { + t.Fatalf("expected no Hermes checks, got warnings=%d results=%+v", warnings, results) + } +} + +func TestDoctorHermesIntegrationWarnsWhenPluginNotEnabled(t *testing.T) { + home, _ := setupHermesDoctorFixture(t, `plugins: + enabled: [] +`) + t.Setenv("PATH", filepath.Join(home, "empty-bin")) + + var results []checkResult + warnings := doctorHermesIntegration(func(name, status, msg string) { + results = append(results, checkResult{Name: name, Status: status, Message: msg}) + }, "http://127.0.0.1:9090", "test-token") + if warnings == 0 { + t.Fatalf("expected warnings, got results=%+v", results) + } + out := doctorResultText(results) + if !strings.Contains(out, "hermes binary was not found") { + t.Fatalf("expected missing-binary warning, got: %s", out) + } + if !strings.Contains(out, "not listed in plugins.enabled") { + t.Fatalf("expected plugin-enabled warning, got: %s", out) + } +} + +func TestDoctorHermesIntegrationReportsReadyExperimentalGate(t *testing.T) { + home, _ := setupHermesDoctorFixture(t, `plugins: + enabled: + - rampart + entries: + rampart: + config: + endpoint_mode: preflight + fail_open_tools: + - read_file + - search_files +`) + t.Setenv("PATH", filepath.Join(home, "empty-bin")) + + var results []checkResult + warnings := doctorHermesIntegration(func(name, status, msg string) { + results = append(results, checkResult{Name: name, Status: status, Message: msg}) + }, "http://127.0.0.1:9090", "test-token") + if warnings != 1 { + t.Fatalf("expected only missing-binary warning, got warnings=%d results=%+v", warnings, results) + } + out := doctorResultText(results) + if !strings.Contains(out, "experimental policy gate configured") { + t.Fatalf("expected Hermes readiness message, got: %s", out) + } + if !strings.Contains(out, "full approval/resume support requires") { + t.Fatalf("expected support-tier boundary, got: %s", out) + } +} + +func TestDoctorHermesIntegrationWarnsOnToolModeAndRiskyFailOpen(t *testing.T) { + home, _ := setupHermesDoctorFixture(t, `plugins: + enabled: + - rampart + entries: + rampart: + config: + endpoint_mode: tool + fail_open_tools: + - read_file + - terminal +`) + t.Setenv("PATH", filepath.Join(home, "empty-bin")) + + var results []checkResult + warnings := doctorHermesIntegration(func(name, status, msg string) { + results = append(results, checkResult{Name: name, Status: status, Message: msg}) + }, "http://127.0.0.1:9090", "test-token") + if warnings < 3 { + t.Fatalf("expected missing-binary, endpoint-mode, and fail-open warnings, got warnings=%d results=%+v", warnings, results) + } + out := doctorResultText(results) + if !strings.Contains(out, "endpoint_mode=tool") { + t.Fatalf("expected tool-mode warning, got: %s", out) + } + if !strings.Contains(out, "terminal") || !strings.Contains(out, "mutating/high-risk") { + t.Fatalf("expected risky fail-open warning, got: %s", out) + } +} + +func setupHermesDoctorFixture(t *testing.T, config string) (home string, hermesHome string) { + t.Helper() + home = t.TempDir() + testSetHome(t, home) + hermesHome = filepath.Join(home, "hermes") + t.Setenv("HERMES_HOME", hermesHome) + pluginDir := filepath.Join(hermesHome, "plugins", "rampart") + requireNoErr(t, os.MkdirAll(pluginDir, 0o755)) + requireNoErr(t, os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(`name: rampart +version: 1.2.0 +provides_hooks: + - pre_tool_call +`), 0o644)) + requireNoErr(t, os.WriteFile(filepath.Join(pluginDir, "__init__.py"), []byte("def register(ctx):\n pass\n"), 0o644)) + requireNoErr(t, os.WriteFile(filepath.Join(hermesHome, "config.yaml"), []byte(config), 0o644)) + return home, hermesHome +} + +func doctorResultText(results []checkResult) string { + var b strings.Builder + for _, result := range results { + b.WriteString(result.Name) + b.WriteString(": ") + b.WriteString(result.Status) + b.WriteString(": ") + b.WriteString(result.Message) + b.WriteByte('\n') + } + return b.String() +} + func TestIsReleaseVersion(t *testing.T) { tests := []struct { version string diff --git a/docs-site/getting-started/release-compatibility-gate.md b/docs-site/getting-started/release-compatibility-gate.md new file mode 100644 index 00000000..b7cc3de8 --- /dev/null +++ b/docs-site/getting-started/release-compatibility-gate.md @@ -0,0 +1,73 @@ +--- +title: Release Compatibility Gate +description: "How Rampart validates advertised agent integrations before release, including latest OpenClaw and experimental Hermes Agent checks." +--- + +# Release Compatibility Gate + +Use this checklist before publishing a Rampart release that advertises agent integration support. It keeps support claims tied to evidence from the exact candidate build, not a prior local install or stale plugin copy. + +## Support tiers + +Rampart uses these tiers in the support matrix: + +- **Recommended**: actively tested against the current stable runtime, polished approval UX, and clear `rampart doctor` checks. +- **Supported**: documented and regression-covered, with narrower UX or less frequent runtime smoke coverage. +- **Experimental**: installable and useful, but with known limits that are still part of the public contract. +- **Legacy compatibility**: maintained where practical for older clients, but not the preferred path. + +Hermes Agent remains **experimental** until Hermes exposes a stable plugin approval/resume primitive and a full end-to-end test proves a single user-facing approval, exact tool-call resume, deny non-bypass, and audit/result correlation. + +## Required gate for release candidates + +1. **Start from a clean candidate** + - Fetch the target release branch and validate a clean worktree. + - Build the exact candidate commit. + - Reinstall bundled agent plugins from that exact build. + - Compare binary version, plugin manifest version, and runtime-reported plugin version. + +2. **Check upstream version currency** + - Record latest stable OpenClaw from npm. + - Record latest stable Hermes Agent from PyPI. + - Treat upstream release notes touching plugin discovery, hook dispatch, approval behavior, native tool relay, model/tool execution, or security boundaries as compatibility-relevant. + +3. **Validate latest stable OpenClaw** + - Use a controlled OpenClaw state, not a dirty local gateway config. + - Install the candidate Rampart OpenClaw plugin. + - Run `openclaw config validate` when available. + - Confirm plugin metadata reports the candidate version and startup activation. + - Exercise allow, ask, and deny with a unique marker. + - For command execution paths, require OpenClaw runtime evidence plus a correlated Rampart audit event for canonical `exec`. + - Check for stale shim, dist patch, or duplicate enforcement paths before calling the result clean. + +4. **Validate latest stable Hermes Agent in isolation** + - Use a temporary `HERMES_HOME` or temporary home directory. + - Install latest Hermes Agent in a temporary Python environment. + - Install the candidate Rampart plugin into only that temporary Hermes plugin directory. + - Enable only the temporary Hermes config. + - Exercise the real Hermes plugin dispatcher, including plugin discovery and `pre_tool_call` hook registration. + - Prove deny blocks before execution, allow continues, `ask` blocks with an approval-required/no-resume message, and mutating tools fail closed when Rampart is unavailable. + - Do not restart or mutate a live Discord, Telegram, or other long-running Hermes gateway for this gate. + +5. **Run `rampart doctor` as a support-contract check** + - Group findings by integration surface. + - Classify each finding as blocker, expected optional local gap, or follow-up diagnostic improvement. + - Do not collapse OpenClaw, Hermes, Claude Code, Codex, and Cline findings into one global yes/no. + +6. **Publish claims that match the evidence** + - OpenClaw can be called recommended only when the latest stable path has fresh runtime/audit proof. + - Hermes can be called an experimental policy gate when isolated latest-Hermes plugin dispatch has deny, allow, ask-block, and fail-closed proof. + - Full Hermes support requires Hermes-owned approval/resume APIs plus live or staging end-to-end validation. + +## CI and local compatibility scripts + +The repository includes compatibility harnesses for latest upstream agent checks: + +```bash +python scripts/compat-hermes-latest.py +node scripts/compat-openclaw-latest.mjs +``` + +The Hermes harness installs or uses an isolated Hermes runtime and never touches the active gateway. The OpenClaw harness expects `openclaw` to be on `PATH`, uses a temporary home/state directory, and validates plugin installation plus the bundled plugin behavior checks. + +A scheduled/manual GitHub Actions workflow runs these upstream checks outside the core unit-test matrix so external upstream breakage is visible without destabilizing ordinary pull-request CI. diff --git a/docs-site/getting-started/support-matrix.md b/docs-site/getting-started/support-matrix.md index 44ea0e9f..d73141cd 100644 --- a/docs-site/getting-started/support-matrix.md +++ b/docs-site/getting-started/support-matrix.md @@ -7,6 +7,8 @@ description: "Supported Rampart integration modes, coverage, approval UX, serve Use this page as the canonical support contract for Rampart's main integration surfaces. +For release-candidate validation and latest-agent checks, use the [Release Compatibility Gate](release-compatibility-gate.md). The support tier below should match the most recent evidence from the exact Rampart candidate build and bundled plugin metadata. + ## At a glance @@ -97,7 +99,7 @@ Use this page as the canonical support contract for Rampart's main integration s - **Claude Code** → best overall native path - **Codex CLI** → best CLI path when you want strong coverage -- **OpenClaw >= 2026.5.2** → best OpenClaw path for 1.0; plugin + native approval UI +- **OpenClaw >= 2026.5.2** → best OpenClaw path; plugin + native approval UI - **Hermes Agent** → experimental plugin path for early testing; `ask` decisions block rather than resume - **Cline** → good supported path, but less polished approval UX than Claude Code @@ -120,6 +122,7 @@ Use this page as the canonical support contract for Rampart's main integration s ## Related guides - [Quick Start](quickstart.md) +- [Release Compatibility Gate](release-compatibility-gate.md) - [How Rampart Works](how-it-works.md) - [OpenClaw integration](../integrations/openclaw.md) - [Hermes Agent integration](../integrations/hermes.md) diff --git a/docs-site/integrations/hermes.md b/docs-site/integrations/hermes.md index 97fef116..7f72918a 100644 --- a/docs-site/integrations/hermes.md +++ b/docs-site/integrations/hermes.md @@ -101,14 +101,22 @@ Use this only when you understand the approval ownership tradeoff. If a policy r ## Verification -Use a deny rule for a harmless command and confirm Hermes blocks it before execution: +Use the isolated latest-Hermes compatibility harness before enabling the plugin on a live gateway: + +```bash +python scripts/compat-hermes-latest.py +``` + +The harness creates a temporary Hermes state, installs the Rampart plugin there, exercises Hermes plugin discovery plus `pre_tool_call` dispatch, and verifies deny, allow, `ask` blocking, and fail-closed behavior without restarting any long-running Hermes gateway. + +For manual verification, use a deny rule for a harmless command and confirm Hermes blocks it before execution: ```bash rampart serve --addr 127.0.0.1 --port 9090 hermes plugins list ``` -Then ask Hermes to run a command that policy denies, such as a destructive-command test pattern. The tool response should include a message beginning with `rampart:` and the command should not execute. +Then ask Hermes to run a command that policy denies, such as a harmless unique-marker test pattern. The tool response should include a message beginning with `rampart:` and the command should not execute. ## Uninstall diff --git a/docs-site/integrations/openclaw.md b/docs-site/integrations/openclaw.md index bbb01206..3d85a852 100644 --- a/docs-site/integrations/openclaw.md +++ b/docs-site/integrations/openclaw.md @@ -16,7 +16,7 @@ For sensitive tools, the recommended operating assumption is simple: if Rampart - **OpenClaw 2026.4.29 - 2026.5.1**: Supported for native plugin startup/interception; plugin approval delivery was not the launch baseline. - **OpenClaw 2026.3.28 - 2026.4.28**: Native plugin works for tool enforcement, but Rampart's polished approval path is supported on newer OpenClaw builds. - **OpenClaw < 2026.3.28**: Legacy shim + bridge — exec-only coverage, requires re-patching after upgrades. - - **Verified 1.0 launch dogfood on**: OpenClaw 2026.5.6 + - Refresh release claims against the latest stable OpenClaw before publishing a new Rampart release. See the [Release Compatibility Gate](../getting-started/release-compatibility-gate.md). `rampart setup openclaw` auto-detects your version and uses the right method. @@ -153,6 +153,12 @@ For end-to-end confidence, validate one case in each state: - fresh ask, for example `sudo id` - hard deny, for example `rm -rf /tmp` +For release-candidate validation, run the latest-OpenClaw compatibility harness in a temporary state directory: + +```bash +node scripts/compat-openclaw-latest.mjs +``` + Or check plugin status directly: ```bash diff --git a/mkdocs.yml b/mkdocs.yml index e23f9488..198825cd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -121,6 +121,7 @@ nav: - Installation: getting-started/installation.md - Quick Start: getting-started/quickstart.md - Support Matrix: getting-started/support-matrix.md + - Release Compatibility Gate: getting-started/release-compatibility-gate.md - How It Works: getting-started/how-it-works.md - "Tutorial: 5 Minutes to Protected": getting-started/tutorial.md - Configuration: getting-started/configuration.md diff --git a/scripts/compat-hermes-latest.py b/scripts/compat-hermes-latest.py new file mode 100644 index 00000000..5f9740b2 --- /dev/null +++ b/scripts/compat-hermes-latest.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Validate Rampart's Hermes plugin against an isolated Hermes runtime. + +This harness is intentionally isolated. It creates a temporary home/HERMES_HOME, +installs the candidate Rampart plugin there, and exercises Hermes' real plugin +discovery plus pre_tool_call dispatcher without touching a live Hermes gateway. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import textwrap +import threading +import venv +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class RampartStub(BaseHTTPRequestHandler): + requests_seen: list[dict[str, Any]] = [] + + def do_POST(self) -> None: # noqa: N802, inherited name + length = int(self.headers.get("Content-Length", "0") or "0") + raw = self.rfile.read(length).decode("utf-8") if length else "{}" + try: + payload = json.loads(raw) + except json.JSONDecodeError: + payload = {} + + params = payload.get("params") if isinstance(payload, dict) else {} + if not isinstance(params, dict): + params = {} + command = str(params.get("command") or "") + record = { + "path": self.path, + "agent": payload.get("agent") if isinstance(payload, dict) else None, + "session": payload.get("session") if isinstance(payload, dict) else None, + "tool_call_id_present": bool(payload.get("tool_call_id")) if isinstance(payload, dict) else False, + "command_marker": _marker_from_command(command), + } + self.requests_seen.append(record) + + if "rampart-deny-marker" in command: + body = { + "decision": "deny", + "message": "blocked by compatibility harness", + "policy": "compat-deny", + "audit_id": "compat-audit-deny", + } + elif "rampart-ask-marker" in command: + body = { + "decision": "ask", + "message": "requires compatibility harness approval", + "matched_policies": ["compat-ask"], + "audit_id": "compat-audit-ask", + } + else: + body = { + "decision": "allow", + "message": "allowed by compatibility harness", + "audit_id": "compat-audit-allow", + } + + encoded = json.dumps(body).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, format: str, *args: Any) -> None: + _ = (format, args) + return + + +def _marker_from_command(command: str) -> str: + for marker in ("rampart-deny-marker", "rampart-ask-marker", "rampart-allow-marker"): + if marker in command: + return marker + return "" + + +def run(cmd: list[str], *, env: dict[str, str] | None = None, cwd: Path = REPO_ROOT) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + cwd=str(cwd), + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + + +def make_venv(root: Path, package: str) -> tuple[Path, Path]: + venv_dir = root / "venv" + venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir) + python = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + hermes = venv_dir / ("Scripts/hermes.exe" if os.name == "nt" else "bin/hermes") + run([str(python), "-m", "pip", "install", "--upgrade", "pip"], cwd=root) + run([str(python), "-m", "pip", "install", package], cwd=root) + return python, hermes + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def write_hermes_config(hermes_home: Path, serve_url: str) -> None: + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "config.yaml").write_text( + textwrap.dedent( + f""" + plugins: + enabled: + - rampart + entries: + rampart: + config: + serve_url: {serve_url} + endpoint_mode: preflight + timeout_ms: 1000 + fail_open_tools: + - read_file + - search_files + """ + ).lstrip(), + encoding="utf-8", + ) + + +def child_probe_code(unused_port: int) -> str: + return textwrap.dedent( + f""" + import json + import os + from hermes_cli.plugins import discover_plugins, get_plugin_manager, get_pre_tool_call_block_message + + discover_plugins(force=True) + manager = get_plugin_manager() + loaded = manager._plugins.get('rampart') + if loaded is None: + raise SystemExit('rampart plugin was not discovered') + if not loaded.enabled: + raise SystemExit(f'rampart plugin discovered but not enabled: {{loaded.error}}') + hooks = manager._hooks.get('pre_tool_call') or [] + if not hooks: + raise SystemExit('rampart plugin did not register a pre_tool_call hook') + + def block(tool, args, call_id): + return get_pre_tool_call_block_message( + tool, + args, + task_id='compat-task', + session_id='compat-session', + tool_call_id=call_id, + ) + + deny = block('terminal', {{'command': 'printf rampart-deny-marker'}}, 'compat-deny-call') + if not deny or 'blocked by compatibility harness' not in deny or 'compat-audit-deny' not in deny: + raise SystemExit(f'deny did not block as expected: {{deny!r}}') + + ask = block('terminal', {{'command': 'printf rampart-ask-marker'}}, 'compat-ask-call') + if not ask or 'approval required' not in ask or 'does not yet resume' not in ask or 'compat-audit-ask' not in ask: + raise SystemExit(f'ask did not block with no-resume message: {{ask!r}}') + + allow = block('terminal', {{'command': 'printf rampart-allow-marker'}}, 'compat-allow-call') + if allow is not None: + raise SystemExit(f'allow should continue without a block message: {{allow!r}}') + + os.environ['RAMPART_HERMES_URL'] = 'http://127.0.0.1:{unused_port}' + os.environ['RAMPART_HERMES_TIMEOUT_MS'] = '250' + fail_closed = block('terminal', {{'command': 'printf rampart-fail-closed-marker'}}, 'compat-fail-closed-call') + if not fail_closed or 'unavailable' not in fail_closed: + raise SystemExit(f'mutating terminal did not fail closed: {{fail_closed!r}}') + + fail_open = block('read_file', {{'path': '/tmp/rampart-compat-read.txt'}}, 'compat-fail-open-call') + if fail_open is not None: + raise SystemExit(f'configured read_file fail-open should continue: {{fail_open!r}}') + + print(json.dumps({{ + 'ok': True, + 'plugin_version': loaded.manifest.version, + 'hooks_registered': loaded.hooks_registered, + 'pre_tool_call_hooks': len(hooks), + 'deny_blocked': True, + 'ask_blocked_without_resume': True, + 'allow_continued': True, + 'mutating_fail_closed': True, + 'configured_read_fail_open': True, + }}, sort_keys=True)) + """ + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate Rampart's Hermes plugin against an isolated latest Hermes runtime.") + parser.add_argument("--package", default="hermes-agent", help="pip package spec to install, default: hermes-agent") + parser.add_argument("--hermes-python", help="Use an existing Python interpreter with Hermes installed instead of creating a venv") + parser.add_argument("--hermes-bin", help="Hermes executable to report version from when --hermes-python is used") + parser.add_argument("--keep-temp", action="store_true", help="Keep the temporary directory for debugging") + args = parser.parse_args() + + temp = Path(tempfile.mkdtemp(prefix="rampart-hermes-compat-")) + try: + if args.hermes_python: + hermes_python = Path(args.hermes_python).resolve() + hermes_bin = Path(args.hermes_bin).resolve() if args.hermes_bin else shutil.which("hermes") + hermes_bin_path = Path(hermes_bin) if hermes_bin else None + else: + hermes_python, hermes_bin_path = make_venv(temp, args.package) + + hermes_home = temp / "hermes-home" + plugin_dir = hermes_home / "plugins" / "rampart" + + server = ThreadingHTTPServer(("127.0.0.1", 0), RampartStub) + serve_url = f"http://127.0.0.1:{server.server_address[1]}" + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + write_hermes_config(hermes_home, serve_url) + env = os.environ.copy() + env.update( + { + "HOME": str(temp / "home"), + "HERMES_HOME": str(hermes_home), + "RAMPART_TOKEN": "compat-test-token", + "RAMPART_HERMES_URL": serve_url, + "RAMPART_HERMES_TIMEOUT_MS": "1000", + "PYTHONWARNINGS": "ignore::DeprecationWarning", + } + ) + Path(env["HOME"]).mkdir(parents=True, exist_ok=True) + + run( + [ + "go", + "run", + "./cmd/rampart", + "setup", + "hermes", + "--plugin-dir", + str(plugin_dir), + ], + env=env, + ) + + hermes_version = "unavailable" + if hermes_bin_path: + try: + version_result = run([str(hermes_bin_path), "--version"], env=env, cwd=temp) + hermes_version = (version_result.stdout or version_result.stderr).strip().splitlines()[0] + except Exception: + hermes_version = "version command failed" + + probe = run([str(hermes_python), "-c", child_probe_code(free_port())], env=env, cwd=temp) + child_summary = json.loads(probe.stdout.strip().splitlines()[-1]) + + paths = {entry["path"] for entry in RampartStub.requests_seen} + if "/v1/preflight/exec" not in paths: + raise RuntimeError(f"expected /v1/preflight/exec request, saw {sorted(paths)}") + markers = {entry["command_marker"] for entry in RampartStub.requests_seen} + expected = {"rampart-deny-marker", "rampart-ask-marker", "rampart-allow-marker"} + if not expected.issubset(markers): + raise RuntimeError(f"expected request markers {sorted(expected)}, saw {sorted(markers)}") + + print( + json.dumps( + { + "ok": True, + "hermes_version": hermes_version, + "hermes_home_isolated": str(hermes_home), + "plugin_dir": str(plugin_dir), + "requests_seen": RampartStub.requests_seen, + "dispatcher": child_summary, + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + finally: + if args.keep_temp: + print(f"kept temporary directory: {temp}", file=sys.stderr) + else: + shutil.rmtree(temp, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/compat-openclaw-latest.mjs b/scripts/compat-openclaw-latest.mjs new file mode 100644 index 00000000..d94ad1ce --- /dev/null +++ b/scripts/compat-openclaw-latest.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * Validate Rampart's OpenClaw plugin against an isolated OpenClaw state. + * + * The script expects an `openclaw` executable on PATH. It sets HOME and + * OPENCLAW_CONFIG_PATH to a temporary directory so plugin install/config checks + * do not touch a live OpenClaw gateway or user config. + */ + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const repoRoot = resolve(new URL('..', import.meta.url).pathname); +const tempRoot = mkdtempSync(join(tmpdir(), 'rampart-openclaw-compat-')); +const tempHome = join(tempRoot, 'home'); +const openclawDir = join(tempHome, '.openclaw'); +const configPath = join(openclawDir, 'openclaw.json'); +const keepTemp = process.argv.includes('--keep-temp'); + +function run(command, args, { required = true, env = compatEnv() } = {}) { + const result = spawnSync(command, args, { + cwd: repoRoot, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const record = { + command: [command, ...args].join(' '), + status: result.status, + stdout: (result.stdout || '').trim(), + stderr: (result.stderr || '').trim(), + }; + if (required && result.status !== 0) { + const detail = JSON.stringify(record, null, 2); + throw new Error(`command failed: ${detail}`); + } + return record; +} + +function compatEnv() { + return { + ...process.env, + HOME: tempHome, + USERPROFILE: tempHome, + XDG_CONFIG_HOME: join(tempHome, '.config'), + OPENCLAW_CONFIG_PATH: configPath, + RAMPART_TOKEN: 'compat-test-token', + RAMPART_URL: 'http://127.0.0.1:19090', + }; +} + +function setupTempState() { + mkdirSync(openclawDir, { recursive: true }); + writeFileSync( + configPath, + JSON.stringify( + { + plugins: { + entries: {}, + }, + tools: { + exec: { + ask: 'off', + }, + }, + }, + null, + 2, + ), + ); +} + +function commandOutput(record) { + return record.stdout || record.stderr || ''; +} + +function main() { + setupTempState(); + const env = compatEnv(); + const pluginDir = join(repoRoot, 'internal', 'plugin', 'openclaw'); + + const version = run('openclaw', ['--version'], { env }); + const install = run('openclaw', ['plugins', 'install', pluginDir], { env }); + const validate = run('openclaw', ['config', 'validate'], { env }); + const inspect = run('openclaw', ['plugins', 'inspect', 'rampart'], { required: false, env }); + const list = inspect.status === 0 + ? { command: 'openclaw plugins list', status: 0, stdout: '', stderr: '' } + : run('openclaw', ['plugins', 'list'], { env }); + + const installedOutput = `${commandOutput(inspect)}\n${commandOutput(list)}\n${commandOutput(install)}`; + if (!installedOutput.includes('rampart')) { + throw new Error(`OpenClaw plugin output did not mention rampart: ${installedOutput}`); + } + + const smoke = run(process.execPath, ['internal/plugin/openclaw/smoke-test.mjs'], { env }); + const approvals = run(process.execPath, ['internal/plugin/openclaw/approval-regression.mjs'], { env }); + const degraded = run(process.execPath, ['internal/plugin/openclaw/degraded-mode-test.mjs'], { env }); + + const summary = { + ok: true, + temp_home: tempHome, + openclaw_version: commandOutput(version).split('\n')[0], + plugin_install_checked: true, + config_validate_checked: validate.status === 0, + plugin_inspect_checked: inspect.status === 0, + plugin_list_checked: inspect.status !== 0 && list.status === 0, + bundled_plugin_harnesses: { + smoke: JSON.parse(smoke.stdout), + approvals: JSON.parse(approvals.stdout), + degraded: JSON.parse(degraded.stdout), + }, + }; + console.log(JSON.stringify(summary, null, 2)); +} + +try { + main(); +} finally { + if (keepTemp) { + console.error(`kept temporary directory: ${tempRoot}`); + } else { + rmSync(tempRoot, { recursive: true, force: true }); + } +} From b50ab983dc4aa4a3efe7b7ffad9b824929c0a882 Mon Sep 17 00:00:00 2001 From: "clap [bot]" Date: Fri, 29 May 2026 18:40:56 +0000 Subject: [PATCH 7/9] docs: clarify OpenClaw runtime release proof --- docs-site/getting-started/release-compatibility-gate.md | 8 ++++++++ docs-site/integrations/openclaw.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/docs-site/getting-started/release-compatibility-gate.md b/docs-site/getting-started/release-compatibility-gate.md index b7cc3de8..b043c953 100644 --- a/docs-site/getting-started/release-compatibility-gate.md +++ b/docs-site/getting-started/release-compatibility-gate.md @@ -70,4 +70,12 @@ node scripts/compat-openclaw-latest.mjs The Hermes harness installs or uses an isolated Hermes runtime and never touches the active gateway. The OpenClaw harness expects `openclaw` to be on `PATH`, uses a temporary home/state directory, and validates plugin installation plus the bundled plugin behavior checks. +For OpenClaw's recommended support tier, also run the opt-in runtime audit regression before a release promotion: + +```bash +RAMPART_OPENCLAW_RUNTIME=1 node scripts/test-openclaw-codex-native-audit.mjs +``` + +That live regression is intentionally separate from scheduled CI because it temporarily enables the OpenClaw Rampart plugin, restarts local OpenClaw user services, runs one real OpenClaw Codex app-server turn, and requires correlated OpenClaw trajectory plus Rampart canonical `exec` audit proof. + A scheduled/manual GitHub Actions workflow runs these upstream checks outside the core unit-test matrix so external upstream breakage is visible without destabilizing ordinary pull-request CI. diff --git a/docs-site/integrations/openclaw.md b/docs-site/integrations/openclaw.md index 3d85a852..10057e77 100644 --- a/docs-site/integrations/openclaw.md +++ b/docs-site/integrations/openclaw.md @@ -159,6 +159,14 @@ For release-candidate validation, run the latest-OpenClaw compatibility harness node scripts/compat-openclaw-latest.mjs ``` +That isolated harness validates plugin install/config and bundled plugin behavior. Before promoting a release that claims OpenClaw as recommended, also run the opt-in live runtime audit regression: + +```bash +RAMPART_OPENCLAW_RUNTIME=1 node scripts/test-openclaw-codex-native-audit.mjs +``` + +The live regression temporarily enables the Rampart OpenClaw plugin and restarts local OpenClaw user services, then requires a real OpenClaw Codex app-server turn with correlated trajectory and Rampart canonical `exec` audit evidence. + Or check plugin status directly: ```bash From 2c955c2f32ce074647d9e2d912dc3fd7281dc8f0 Mon Sep 17 00:00:00 2001 From: "clap [bot]" Date: Fri, 29 May 2026 23:50:30 +0000 Subject: [PATCH 8/9] fix: report Hermes plugin readiness in doctor --- cmd/rampart/cli/doctor.go | 49 +++++++++++++++++ cmd/rampart/cli/doctor_test.go | 45 ++++++++++++++++ cmd/rampart/cli/status.go | 96 ++++++++++++++++++++++++++++++++++ cmd/rampart/cli/status_test.go | 48 +++++++++++++++++ 4 files changed, 238 insertions(+) diff --git a/cmd/rampart/cli/doctor.go b/cmd/rampart/cli/doctor.go index 658de6eb..4bd77395 100644 --- a/cmd/rampart/cli/doctor.go +++ b/cmd/rampart/cli/doctor.go @@ -306,6 +306,9 @@ func runDoctor(w io.Writer, jsonOut bool) error { if n := doctorOpenClawDiscordApprovalRuntime(emit); n > 0 { warnings += n } + if n := doctorHermesPlugin(emit, serveURL); n > 0 { + warnings += n + } // 17. OpenClaw ask mode — only needed for legacy bridge users. // With the native plugin active, before_tool_call covers all tool calls @@ -678,6 +681,52 @@ func hasPermissiveAllowUnmatched(cfg *engine.Config) bool { return false } +func doctorHermesPlugin(emit emitFn, serveURL string) (warnings int) { + state := detectHermesPluginState() + _, hermesBinErr := exec.LookPath("hermes") + hermesDetected := hermesBinErr == nil || state.ConfigPresent || state.Installed + if !hermesDetected { + return 0 + } + if !state.Installed { + emit("Hermes Agent plugin", "warn", + "Hermes detected but Rampart plugin is not installed"+ + hintSep+"rampart setup hermes --enable") + return 1 + } + if !state.ManifestValid { + emit("Hermes Agent plugin", "warn", + fmt.Sprintf("Rampart Hermes plugin manifest is invalid at %s", filepath.Join(state.PluginDir, "plugin.yaml"))+ + hintSep+"rampart setup hermes") + return 1 + } + if !state.HookDeclared { + emit("Hermes Agent plugin", "warn", + "Rampart Hermes plugin manifest does not declare pre_tool_call"+ + hintSep+"rampart setup hermes") + return 1 + } + if !state.Enabled { + emit("Hermes Agent plugin", "warn", + "Rampart Hermes plugin is installed but not enabled"+ + hintSep+"hermes plugins enable rampart && restart long-running Hermes gateways") + return 1 + } + + version := state.Version + if version == "" { + version = "unknown version" + } else { + version = "v" + strings.TrimPrefix(version, "v") + } + msg := fmt.Sprintf("installed and enabled (%s, pre_tool_call hook declared)", version) + if serveURL != "" { + msg += fmt.Sprintf("; serve reachable at %s", serveURL) + } + emit("Hermes Agent plugin", "ok", msg) + return 0 +} + // doctorServer checks if rampart serve is running on defaultServePort. // Returns (issue count, serve URL for subsequent API checks). diff --git a/cmd/rampart/cli/doctor_test.go b/cmd/rampart/cli/doctor_test.go index 7d8f9c95..37a390d6 100644 --- a/cmd/rampart/cli/doctor_test.go +++ b/cmd/rampart/cli/doctor_test.go @@ -640,6 +640,51 @@ func TestDoctorCoverage_OpenClawOnlyNoClaude(t *testing.T) { } } +func TestDoctorHermesPluginEnabled(t *testing.T) { + home := t.TempDir() + testSetHome(t, home) + t.Setenv("PATH", t.TempDir()) + writeHermesRampartPluginFixture(t, home, "plugins:\n enabled:\n - rampart\n") + + var results []checkResult + emit := func(name, status, msg string) { + results = append(results, checkResult{Name: name, Status: status, Message: msg}) + } + warnings := doctorHermesPlugin(emit, "http://localhost:9090") + if warnings != 0 { + t.Fatalf("expected no warnings for enabled Hermes plugin, got %d (%+v)", warnings, results) + } + if len(results) != 1 || results[0].Name != "Hermes Agent plugin" || results[0].Status != "ok" { + t.Fatalf("expected ok Hermes Agent plugin check, got %+v", results) + } + if !strings.Contains(results[0].Message, "v1.2.0") || !strings.Contains(results[0].Message, "pre_tool_call") { + t.Fatalf("expected version and hook in message, got %q", results[0].Message) + } +} + +func TestDoctorHermesPluginMissingWhenHermesDetected(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PATH shim binaries in this test are Unix-only") + } + home := t.TempDir() + testSetHome(t, home) + binDir := t.TempDir() + writeTestExecutable(t, filepath.Join(binDir, "hermes")) + t.Setenv("PATH", binDir) + + var results []checkResult + emit := func(name, status, msg string) { + results = append(results, checkResult{Name: name, Status: status, Message: msg}) + } + warnings := doctorHermesPlugin(emit, "http://localhost:9090") + if warnings != 1 { + t.Fatalf("expected one warning for missing Hermes plugin, got %d (%+v)", warnings, results) + } + if len(results) != 1 || results[0].Status != "warn" || !strings.Contains(results[0].Message, "not installed") { + t.Fatalf("expected missing-plugin warning, got %+v", results) + } +} + func TestDoctorOpenClawPlugin(t *testing.T) { skipOnWindows(t, "PATH shim binaries in this test are Unix-only") diff --git a/cmd/rampart/cli/status.go b/cmd/rampart/cli/status.go index 09075b3d..27758ec5 100644 --- a/cmd/rampart/cli/status.go +++ b/cmd/rampart/cli/status.go @@ -27,6 +27,7 @@ import ( "github.com/peg/rampart/internal/build" "github.com/peg/rampart/internal/engine" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) func newStatusCmd() *cobra.Command { @@ -400,9 +401,104 @@ func detectProtectedAgents() []string { agents = append(agents, "Codex (wrapper)") } + // Hermes Agent user plugin installed by `rampart setup hermes`. + if state := detectHermesPluginStateForHome(home); state.Installed && state.Enabled && state.ManifestValid && state.HookDeclared { + agents = append(agents, "Hermes Agent (plugin)") + } + return agents } +type hermesPluginState struct { + Installed bool + Enabled bool + ConfigPresent bool + ManifestValid bool + HookDeclared bool + Version string + PluginDir string +} + +type hermesPluginManifest struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + ProvidesHooks []string `yaml:"provides_hooks"` +} + +type hermesConfigFile struct { + Plugins hermesConfigPlugins `yaml:"plugins"` +} + +type hermesConfigPlugins struct { + Enabled []string `yaml:"enabled"` + Disabled []string `yaml:"disabled"` + Entries map[string]hermesPluginConfig `yaml:"entries"` +} + +type hermesPluginConfig struct { + Enabled *bool `yaml:"enabled"` +} + +func detectHermesPluginState() hermesPluginState { + home, err := os.UserHomeDir() + if err != nil { + return hermesPluginState{} + } + return detectHermesPluginStateForHome(home) +} + +func detectHermesPluginStateForHome(home string) hermesPluginState { + state := hermesPluginState{PluginDir: filepath.Join(home, ".hermes", "plugins", "rampart")} + manifestPath := filepath.Join(state.PluginDir, "plugin.yaml") + data, err := os.ReadFile(manifestPath) + if err == nil { + state.Installed = true + var manifest hermesPluginManifest + if yaml.Unmarshal(data, &manifest) == nil { + state.ManifestValid = strings.TrimSpace(manifest.Name) == "rampart" + state.Version = strings.TrimSpace(manifest.Version) + for _, hook := range manifest.ProvidesHooks { + if strings.TrimSpace(hook) == "pre_tool_call" { + state.HookDeclared = true + break + } + } + } + } + + configPath := filepath.Join(home, ".hermes", "config.yaml") + configData, err := os.ReadFile(configPath) + if err != nil { + return state + } + state.ConfigPresent = true + + var cfg hermesConfigFile + if yaml.Unmarshal(configData, &cfg) != nil { + return state + } + if stringListContains(cfg.Plugins.Disabled, "rampart") { + state.Enabled = false + return state + } + if stringListContains(cfg.Plugins.Enabled, "rampart") { + state.Enabled = true + } + if entry, ok := cfg.Plugins.Entries["rampart"]; ok && entry.Enabled != nil { + state.Enabled = *entry.Enabled + } + return state +} + +func stringListContains(values []string, want string) bool { + for _, value := range values { + if strings.TrimSpace(value) == want { + return true + } + } + return false +} + func hasLegacyOpenClawBridgeConfig(data []byte) bool { var cfg map[string]any if err := json.Unmarshal(data, &cfg); err != nil { diff --git a/cmd/rampart/cli/status_test.go b/cmd/rampart/cli/status_test.go index d27d0cf5..42f4a078 100644 --- a/cmd/rampart/cli/status_test.go +++ b/cmd/rampart/cli/status_test.go @@ -189,6 +189,54 @@ func TestDetectProtectedAgents_IgnoresPlainCodexBinary(t *testing.T) { } } +func TestDetectProtectedAgents_HermesPluginEnabled(t *testing.T) { + home := t.TempDir() + testSetHome(t, home) + writeHermesRampartPluginFixture(t, home, "plugins:\n enabled:\n - rampart\n") + + found := false + for _, agent := range detectProtectedAgents() { + if agent == "Hermes Agent (plugin)" { + found = true + break + } + } + if !found { + t.Fatalf("expected Hermes Agent plugin detection, got %v", detectProtectedAgents()) + } +} + +func TestDetectProtectedAgents_HermesPluginDisabled(t *testing.T) { + home := t.TempDir() + testSetHome(t, home) + writeHermesRampartPluginFixture(t, home, "plugins:\n enabled:\n - rampart\n disabled:\n - rampart\n") + + for _, agent := range detectProtectedAgents() { + if agent == "Hermes Agent (plugin)" { + t.Fatalf("disabled Hermes plugin should not be reported as protected: %v", agent) + } + } +} + +func writeHermesRampartPluginFixture(t *testing.T, home, config string) { + t.Helper() + pluginDir := filepath.Join(home, ".hermes", "plugins", "rampart") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + manifest := "name: rampart\nversion: 1.2.0\nprovides_hooks:\n - pre_tool_call\n" + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(home, ".hermes", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil { + t.Fatal(err) + } +} + func TestDetectProtectedAgents_OpenClawPluginRequiresAllowedAndEnabled(t *testing.T) { home := t.TempDir() testSetHome(t, home) From 7a0d981e937200c8c914375ac402312a3db0e8bd Mon Sep 17 00:00:00 2001 From: "clap [bot]" Date: Sat, 30 May 2026 05:12:16 +0000 Subject: [PATCH 9/9] fix: reuse Hermes plugin manifest type --- cmd/rampart/cli/status.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cmd/rampart/cli/status.go b/cmd/rampart/cli/status.go index 27758ec5..02030f0a 100644 --- a/cmd/rampart/cli/status.go +++ b/cmd/rampart/cli/status.go @@ -419,12 +419,6 @@ type hermesPluginState struct { PluginDir string } -type hermesPluginManifest struct { - Name string `yaml:"name"` - Version string `yaml:"version"` - ProvidesHooks []string `yaml:"provides_hooks"` -} - type hermesConfigFile struct { Plugins hermesConfigPlugins `yaml:"plugins"` }