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/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/cmd/rampart/cli/doctor.go b/cmd/rampart/cli/doctor.go index 59d922db..5b11109e 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. @@ -294,6 +295,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 } @@ -303,6 +307,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 @@ -320,6 +327,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() @@ -675,6 +688,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). @@ -1655,6 +1714,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 @@ -1983,6 +2079,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 dc756c02..e82b49ef 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") @@ -651,6 +696,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 +712,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 +766,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 @@ -799,6 +900,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/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.go b/cmd/rampart/cli/status.go index 09075b3d..02030f0a 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,98 @@ 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 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 68524c35..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) @@ -228,6 +276,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") 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/release-compatibility-gate.md b/docs-site/getting-started/release-compatibility-gate.md new file mode 100644 index 00000000..b043c953 --- /dev/null +++ b/docs-site/getting-started/release-compatibility-gate.md @@ -0,0 +1,81 @@ +--- +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. + +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/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