Skip to content

Commit 2a62529

Browse files
authored
Merge pull request #239 from peg/staging
feat: v0.9.7 — bug fixes, exec patch, port fix, allow-always improvements
2 parents e597e62 + 14065dd commit 2a62529

File tree

22 files changed

+817
-701
lines changed

22 files changed

+817
-701
lines changed

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.9.7] - 2026-03-20
11+
12+
### Added
13+
14+
- **Native OpenClaw exec approval integration**: The bridge now correctly receives `exec.approval.requested` events. Root cause: the gateway silently stripped `operator.approvals` scope from clients connecting without a device identity (`clearUnboundScopes()`). Fixed by loading OpenClaw's existing `~/.openclaw/identity/device.json` and including a signed V3 device auth payload in the connect handshake. The bridge now intercepts all exec approvals, evaluates against Rampart policy, and resolves allow/deny decisions before the Discord/Telegram UI shows buttons.
15+
- **"Always Allow" writes to `user-overrides.yaml`**: When a user clicks "Always Allow" on an exec approval, the bridge captures the `exec.approval.resolved` event and writes a persistent rule to `~/.rampart/policies/user-overrides.yaml`. This file is never overwritten by upgrades or `rampart setup`.
16+
- **`patchExecInDist()`**: `rampart setup openclaw --patch-tools` now also patches the exec tool in OpenClaw dist files, adding a Rampart pre-check before OpenClaw's own allowlist evaluation for human-initiated execs.
17+
- **`rampart doctor` improvements**: Granular per-tool patch detection (web_fetch, browser, message, exec checked individually). `--fix` flag auto-applies missing patches without requiring the full setup command. Fixed detection for modern OpenClaw dist format (`auth-profiles-*.js`).
18+
- **`persisted` field in approval poll responses**: `GET /v1/approvals/{id}` now returns `persisted: true` when the resolution was an allow-always decision that wrote a persistent rule.
19+
20+
### Fixed
21+
22+
- **MCP-style input bypass**: `domain_matches` policies were not evaluated for MCP-style `{"input":{"url":"..."}}` requests — `enrichParams()` only processed `req.Params`, not `req.Input`. Now enriches both and promotes fields into `req.Params`.
23+
- **`policy explain` URL params**: `rampart policy explain --tool web_fetch "https://..."` always returned ALLOW because the CLI hardcoded `command=arg` instead of parsing the URL. Now correctly sets domain/scheme/path for URL-based tools.
24+
- **ngrok.io bare domain**: `ngrok.io` was not in the `block-exfil-domains` blocklist (only `*.ngrok.io` was covered). Added `ngrok.io` and `ngrok-free.app` bare domains.
25+
- **allow-always glob pattern**: `GeneralizeCommand` appended `" *"` (space before glob) so commands like `shred /tmp/file` were generalized to `"shred /tmp/file *"`, which didn't match the exact command without extra args. Fixed to `"shred /tmp/file*"`.
26+
- **Startup migration for old glob patterns**: `rampart serve` now automatically migrates existing `auto-allowed.yaml` rules from the old `"cmd arg *"` format to the correct `"cmd arg*"` format on first start after upgrading.
27+
- **Default port consistency**: `rampart setup --port` flag, `rampart status` port probe, service file generation, shim URL, and dist patches all now consistently use port 9090 (was 19090 in some places).
28+
- **`approvalRequestParams` struct**: The bridge was parsing `exec.approval.requested` payloads with a flat struct — command/agentId were nested under `request:{}` in the actual gateway payload, causing the bridge to evaluate empty commands (and auto-allow everything). Fixed with correct nested struct.
29+
- **`rampart doctor` dist detection**: `openclawUsesBundledDist()` only checked for `pi-embedded-*.js` (older OpenClaw) and missed `auth-profiles-*.js` (newer OpenClaw), causing incorrect "not patched" warnings on modern installations.
30+
31+
## [0.9.6] - 2026-03-18
32+
1033
## [0.9.5] - 2026-03-17
1134

1235
### Added

README.md

Lines changed: 234 additions & 602 deletions
Large diffs are not rendered by default.

cmd/rampart/cli/doctor.go

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -53,20 +53,47 @@ const hintSep = "\n 💡 "
5353

5454
func newDoctorCmd() *cobra.Command {
5555
var jsonOut bool
56+
var fix bool
5657

5758
cmd := &cobra.Command{
5859
Use: "doctor",
5960
Short: "Check Rampart installation health",
6061
Long: "Run diagnostic checks on your Rampart installation and report any issues.",
6162
RunE: func(cmd *cobra.Command, _ []string) error {
63+
if fix {
64+
return runDoctorFix(cmd)
65+
}
6266
return runDoctor(cmd.OutOrStdout(), jsonOut)
6367
},
6468
}
6569

6670
cmd.Flags().BoolVar(&jsonOut, "json", false, "Output results as JSON")
71+
cmd.Flags().BoolVar(&fix, "fix", false, "Automatically apply fixes for detected issues (re-runs patch-tools if needed)")
6772
return cmd
6873
}
6974

75+
// runDoctorFix checks for missing patches and applies them automatically.
76+
func runDoctorFix(cmd *cobra.Command) error {
77+
needsPatch := !openclawWebFetchPatched() || !openclawBrowserPatched() ||
78+
!openclawMessagePatched() || !openclawExecPatched() || !openclawDistPatched()
79+
80+
if !needsPatch {
81+
fmt.Fprintln(cmd.OutOrStdout(), "✓ All patches already applied — nothing to fix.")
82+
return nil
83+
}
84+
85+
fmt.Fprintln(cmd.OutOrStdout(), "Applying missing patches...")
86+
_, err := patchOpenClawDistTools(cmd, fmt.Sprintf("http://127.0.0.1:%d", defaultServePort), "")
87+
if err != nil {
88+
// May need sudo — tell the user
89+
fmt.Fprintf(cmd.ErrOrStderr(), "⚠ Auto-fix failed (may need sudo): %v\n", err)
90+
fmt.Fprintf(cmd.ErrOrStderr(), " Run manually: sudo rampart setup openclaw --patch-tools --force\n")
91+
return err
92+
}
93+
fmt.Fprintln(cmd.OutOrStdout(), "✓ Patches applied. Restart OpenClaw for changes to take effect.")
94+
return nil
95+
}
96+
7097
func runDoctor(w io.Writer, jsonOut bool) error {
7198
var results []checkResult
7299
collect := jsonOut // only accumulate when --json is set
@@ -798,19 +825,54 @@ func doctorPreload(emit emitFn) (warnings int) {
798825
func doctorFileToolPatches(emit emitFn) (warnings int) {
799826
// Check if OpenClaw uses bundled dist files (#204).
800827
if openclawUsesBundledDist() {
801-
// Check if dist files are patched
802-
if openclawDistPatched() {
803-
msg := "OpenClaw dist files patched (read + write/edit)"
804-
if openclawWebFetchPatched() {
805-
msg = "OpenClaw dist files patched (read + write/edit + web_fetch)"
806-
}
807-
emit("File tools", "ok", msg)
828+
distPatched := openclawDistPatched()
829+
webFetchPatched := openclawWebFetchPatched()
830+
browserPatched := openclawBrowserPatched()
831+
messagePatched := openclawMessagePatched()
832+
execPatched := openclawExecPatched()
833+
834+
if distPatched && webFetchPatched && browserPatched && messagePatched && execPatched {
835+
emit("Tool patches", "ok", "All OpenClaw tools patched (read/write/edit + web_fetch + browser + message + exec)")
808836
return 0
809837
}
810-
emit("File tools", "warn",
811-
"OpenClaw uses bundled dist files — file tools not policy-checked"+
812-
hintSep+"sudo rampart setup openclaw --patch-tools --force")
813-
return 1
838+
839+
// Report each unpatched tool separately so users know exactly what to fix.
840+
if !distPatched {
841+
emit("Tool patches", "warn",
842+
"OpenClaw dist files not patched — read/write/edit not policy-checked"+
843+
hintSep+"sudo rampart setup openclaw --patch-tools --force")
844+
warnings++
845+
} else {
846+
emit("Tool patches", "ok", "OpenClaw dist files patched (read + write/edit)")
847+
}
848+
if !webFetchPatched {
849+
emit("web_fetch patch", "warn",
850+
"OpenClaw web_fetch not patched — URL fetch requests not policy-checked"+
851+
hintSep+"sudo rampart setup openclaw --patch-tools --force")
852+
warnings++
853+
}
854+
if !browserPatched {
855+
emit("browser patch", "warn",
856+
"OpenClaw browser tool not patched — navigate/open not policy-checked"+
857+
hintSep+"sudo rampart setup openclaw --patch-tools --force")
858+
warnings++
859+
}
860+
if !messagePatched {
861+
emit("message patch", "warn",
862+
"OpenClaw message tool not patched — outbound sends not policy-checked"+
863+
hintSep+"sudo rampart setup openclaw --patch-tools --force")
864+
warnings++
865+
}
866+
if !execPatched {
867+
emit("exec patch", "warn",
868+
"OpenClaw exec tool not patched — human-initiated execs bypass pre-check"+
869+
hintSep+"sudo rampart setup openclaw --patch-tools --force")
870+
warnings++
871+
}
872+
if warnings > 0 {
873+
return warnings
874+
}
875+
return 0
814876
}
815877

816878
candidates := openclawToolsCandidates()
@@ -861,7 +923,10 @@ func openclawUsesBundledDist() bool {
861923
}
862924

863925
for _, distDir := range candidates {
864-
matches, _ := filepath.Glob(filepath.Join(distDir, "pi-embedded-*.js"))
926+
// Check for pi-embedded-*.js (older OpenClaw) or auth-profiles-*.js (newer OpenClaw)
927+
piMatches, _ := filepath.Glob(filepath.Join(distDir, "pi-embedded-*.js"))
928+
authMatches, _ := filepath.Glob(filepath.Join(distDir, "auth-profiles-*.js"))
929+
matches := append(piMatches, authMatches...)
865930
if len(matches) > 0 {
866931
// Bundled dist exists. Check if the bundle contains tool definitions
867932
// (confirming tools are compiled in, not loaded from node_modules).
@@ -870,7 +935,8 @@ func openclawUsesBundledDist() bool {
870935
if err != nil {
871936
continue
872937
}
873-
if strings.Contains(string(data), "createReadTool") || strings.Contains(string(data), "readTool") {
938+
if strings.Contains(string(data), "createReadTool") || strings.Contains(string(data), "readTool") ||
939+
strings.Contains(string(data), "processGatewayAllowlist") || strings.Contains(string(data), "runMessageAction") {
874940
return true
875941
}
876942
}
@@ -880,20 +946,9 @@ func openclawUsesBundledDist() bool {
880946
}
881947

882948
// openclawDistPatched checks if the bundled dist files have been patched with Rampart checks.
949+
// Checks both pi-embedded-*.js (older OpenClaw) and auth-profiles-*.js (newer OpenClaw).
883950
func openclawDistPatched() bool {
884-
for _, d := range openclawDistCandidates() {
885-
matches, _ := filepath.Glob(filepath.Join(d, "pi-embedded-*.js"))
886-
for _, m := range matches {
887-
data, err := os.ReadFile(m)
888-
if err != nil {
889-
continue
890-
}
891-
if strings.Contains(string(data), "RAMPART_DIST_CHECK") {
892-
return true
893-
}
894-
}
895-
}
896-
return false
951+
return openclawDistCheckPatched("RAMPART_DIST_CHECK")
897952
}
898953

899954
// openclawWebFetchPatched checks if any dist *.js files have the web_fetch Rampart patch.
@@ -911,6 +966,11 @@ func openclawMessagePatched() bool {
911966
return openclawDistCheckPatched("RAMPART_DIST_CHECK_MESSAGE")
912967
}
913968

969+
// openclawExecPatched checks if the exec tool is patched.
970+
func openclawExecPatched() bool {
971+
return openclawDistCheckPatched("RAMPART_DIST_CHECK_EXEC")
972+
}
973+
914974
// openclawDistCheckPatched returns true if any dist *.js file contains the given marker.
915975
func openclawDistCheckPatched(marker string) bool {
916976
for _, d := range openclawDistCandidates() {

cmd/rampart/cli/init_from_audit_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ func TestInitFromAudit_BasicGeneration(t *testing.T) {
7474
data, err := os.ReadFile(outputPath)
7575
require.NoError(t, err)
7676
content := string(data)
77-
assert.Contains(t, content, "npm install *")
78-
assert.Contains(t, content, "git push *")
77+
assert.Contains(t, content, "npm install*")
78+
assert.Contains(t, content, "git push*")
7979
}
8080

8181
func TestInitFromAudit_DeniedEventsSkipped(t *testing.T) {
@@ -121,7 +121,7 @@ func TestInitFromAudit_Deduplication(t *testing.T) {
121121
require.NoError(t, err)
122122

123123
output := buf.String()
124-
// All three should deduplicate to "npm install *"
124+
// All three should deduplicate to "npm install*"
125125
assert.Contains(t, output, "1 patterns")
126126
}
127127

@@ -231,5 +231,5 @@ func TestInitFromAudit_Directory(t *testing.T) {
231231
require.NoError(t, err)
232232
content := string(data)
233233
assert.Contains(t, content, "git status")
234-
assert.Contains(t, content, "go test *")
234+
assert.Contains(t, content, "go test*")
235235
}

cmd/rampart/cli/policy.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package cli
1616
import (
1717
"encoding/json"
1818
"fmt"
19+
"net/url"
1920
"os"
2021
"path/filepath"
2122
"sort"
@@ -190,10 +191,21 @@ func newPolicyExplainCmd(opts *rootOptions) *cobra.Command {
190191
return fmt.Errorf("policy: create engine: %w", err)
191192
}
192193

194+
params := map[string]any{"command": command}
195+
// For URL-based tools, parse the argument as a URL and enrich params
196+
// so domain_matches and url_matches policies evaluate correctly.
197+
if tool == "web_fetch" || tool == "fetch" || tool == "http" || tool == "browser" {
198+
params = map[string]any{"url": command}
199+
if parsed, err := url.Parse(command); err == nil && parsed.Host != "" {
200+
params["domain"] = parsed.Hostname()
201+
params["scheme"] = parsed.Scheme
202+
params["path"] = parsed.Path
203+
}
204+
}
193205
call := engine.ToolCall{
194206
Agent: normalizeAgent(agent),
195207
Tool: tool,
196-
Params: map[string]any{"command": command},
208+
Params: params,
197209
Timestamp: time.Now().UTC(),
198210
}
199211
decision := eng.Evaluate(call)

cmd/rampart/cli/serve.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,15 @@ func newServeCmd(opts *rootOptions, deps *serveDeps) *cobra.Command {
228228
store = engine.NewFileStore(opts.configPath)
229229
}
230230

231+
// Migrate old-format allow-always rules (space before glob: "cmd *" → "cmd*").
232+
// Safe to run on every startup — no-ops if already migrated.
233+
autoAllowedPath := engine.DefaultAutoAllowedPath()
234+
if n, mErr := engine.MigrateAllowRuleGlobs(autoAllowedPath); mErr != nil {
235+
logger.Warn("serve: auto-allowed glob migration failed", "error", mErr)
236+
} else if n > 0 {
237+
logger.Info("serve: migrated allow-always glob patterns", "count", n, "path", autoAllowedPath)
238+
}
239+
231240
eng, err := engine.New(store, logger)
232241
if err != nil {
233242
return fmt.Errorf("serve: create engine: %w", err)

0 commit comments

Comments
 (0)