Skip to content

Commit 105556e

Browse files
authored
Merge pull request #248 from peg/staging
fix: bundle OpenClaw plugin in binary + remaining v0.9.12 fixes
2 parents 9962487 + 8700f92 commit 105556e

File tree

9 files changed

+715
-16
lines changed

9 files changed

+715
-16
lines changed

cmd/rampart/cli/mcp.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,8 @@ func newMCPProxyCmd(opts *rootOptions, deps *mcpDeps) *cobra.Command {
255255
cmd.Flags().StringVar(&mode, "mode", "enforce", "Mode: enforce | monitor")
256256
cmd.Flags().StringVar(&auditDir, "audit-dir", "", "Directory for audit logs (default: ~/.rampart/audit)")
257257
cmd.Flags().BoolVar(&filterTools, "filter-tools", false, "Filter denied tools from tools/list responses")
258-
cmd.Flags().StringVar(&agentID, "agent-id", "", "Agent identity for policy evaluation and audit (default: mcp-client)")
259-
cmd.Flags().StringVar(&sessionID, "session-id", "", "Session identity for policy evaluation and audit (default: mcp-proxy)")
258+
cmd.Flags().StringVar(&agentID, "agent-id", "", "Agent identity for policy evaluation and audit (default: mcp-client). Advisory only — not verified by Rampart. Do not use as a security boundary.")
259+
cmd.Flags().StringVar(&sessionID, "session-id", "", "Session identity for policy evaluation and audit (default: mcp-proxy). Advisory only — not verified by Rampart.")
260260

261261
return cmd
262262
}

cmd/rampart/cli/setup_openclaw_plugin.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"strings"
2626
"time"
2727

28+
ocplugin "github.com/peg/rampart/internal/plugin/openclaw"
2829
"github.com/peg/rampart/policies"
2930
)
3031

@@ -36,9 +37,8 @@ const openclawPluginDir = "extensions/rampart"
3637
// before_tool_call hook used by the Rampart plugin.
3738
const openclawMinVersion = "2026.3.28"
3839

39-
// TODO: bundle the plugin inside the rampart binary and extract to a temp dir.
40-
// For now, point at the development checkout path.
41-
const openclawPluginDevPath = "/home/clap/.openclaw/workspace/rampart-openclaw-plugin"
40+
// The plugin is bundled inside the binary via //go:embed and extracted to a
41+
// temp directory during setup. No external checkout or npm install required.
4242

4343
// runSetupOpenClawPlugin installs the Rampart native plugin into OpenClaw.
4444
//
@@ -81,18 +81,23 @@ func runSetupOpenClawPlugin(w io.Writer, errW io.Writer) error {
8181
fmt.Fprintf(w, "✓ OpenClaw version: %s (>= %s required)\n", version, openclawMinVersion)
8282
}
8383

84-
// 3. Install the plugin.
85-
pluginPath := openclawPluginDevPath
86-
if _, err := os.Stat(pluginPath); os.IsNotExist(err) {
87-
return fmt.Errorf("Rampart OpenClaw plugin not found at %s\n Build it first: cd %s && npm install && npm run build", pluginPath, pluginPath)
84+
// 3. Extract the bundled plugin to a temp dir and install it.
85+
pluginDir, err := os.MkdirTemp("", "rampart-openclaw-plugin-*")
86+
if err != nil {
87+
return fmt.Errorf("failed to create temp dir for plugin: %w", err)
88+
}
89+
defer os.RemoveAll(pluginDir)
90+
91+
if err := ocplugin.Extract(pluginDir); err != nil {
92+
return fmt.Errorf("failed to extract bundled plugin: %w", err)
8893
}
89-
fmt.Fprintf(w, "Installing plugin from: %s\n", pluginPath)
94+
fmt.Fprintf(w, "Installing bundled plugin (v%s)...\n", ocplugin.Version())
9095

91-
installCmd := osexec.Command(openclawBin, "plugins", "install", pluginPath)
96+
installCmd := osexec.Command(openclawBin, "plugins", "install", pluginDir)
9297
installCmd.Stdout = w
9398
installCmd.Stderr = errW
9499
if err := installCmd.Run(); err != nil {
95-
return fmt.Errorf("openclaw plugins install failed: %w\n Try running manually: openclaw plugins install %s", err, pluginPath)
100+
return fmt.Errorf("openclaw plugins install failed: %w\n Try running manually: openclaw plugins install <extracted-plugin-path>", err)
96101
}
97102
fmt.Fprintln(w, "✓ Rampart plugin installed into OpenClaw")
98103

internal/bridge/openclaw.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -636,8 +636,28 @@ func (b *OpenClawBridge) writeAllowAlwaysRule(command string) {
636636
b.logger.Error("bridge: allow-always: create policies dir", "error", err)
637637
return
638638
}
639-
if err := os.WriteFile(overridesPath, []byte(existing+rule), 0o600); err != nil {
640-
b.logger.Error("bridge: allow-always: write user-overrides.yaml", "error", err)
639+
// Atomic write: write to temp file then rename to avoid partial reads.
640+
dir := filepath.Dir(overridesPath)
641+
tmp, err := os.CreateTemp(dir, ".rampart-user-overrides-*.yaml.tmp")
642+
if err != nil {
643+
b.logger.Error("bridge: allow-always: create temp file", "error", err)
644+
return
645+
}
646+
tmpPath := tmp.Name()
647+
if _, werr := tmp.WriteString(existing + rule); werr != nil {
648+
tmp.Close()
649+
os.Remove(tmpPath)
650+
b.logger.Error("bridge: allow-always: write temp file", "error", werr)
651+
return
652+
}
653+
if cerr := tmp.Close(); cerr != nil {
654+
os.Remove(tmpPath)
655+
b.logger.Error("bridge: allow-always: close temp file", "error", cerr)
656+
return
657+
}
658+
if rerr := os.Rename(tmpPath, overridesPath); rerr != nil {
659+
os.Remove(tmpPath)
660+
b.logger.Error("bridge: allow-always: rename to final path", "error", rerr)
641661
return
642662
}
643663

internal/plugin/openclaw/README.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# rampart-openclaw-plugin
2+
3+
**Rampart AI agent firewall — native OpenClaw plugin (v1.0)**
4+
5+
This OpenClaw plugin integrates [Rampart](https://github.com/peg/rampart) using the native `before_tool_call` hook API (OpenClaw ≥ 2026.3.28). It is the primary integration path, replacing the legacy dist-file patching approach.
6+
7+
---
8+
9+
## Quick start
10+
11+
```bash
12+
# 1. Install Rampart
13+
go install github.com/peg/rampart@latest
14+
15+
# 2. Start Rampart policy server (defaults to :9090)
16+
rampart serve
17+
18+
# 3. Install this plugin
19+
openclaw plugins install /path/to/rampart-openclaw-plugin
20+
```
21+
22+
That's it. Every tool call is now checked against your Rampart policies.
23+
24+
---
25+
26+
## What happens on each decision
27+
28+
Every time the OpenClaw agent calls a tool (`exec`, `read`, `write`, `web_fetch`, `message`, etc.), this plugin intercepts the call **before it executes** and checks it against the running Rampart policy engine (`rampart serve`).
29+
30+
| Decision | What happens |
31+
|----------|-------------|
32+
| `allow` | Tool call proceeds normally |
33+
| `deny` | Tool call is blocked; agent sees a `blockReason` message |
34+
| `ask` | OpenClaw pauses and prompts you for approval (120 s timeout → auto-deny) |
35+
36+
### The always-allow flow
37+
38+
When Rampart returns `ask` and you click **Allow Always** in the OpenClaw approval UI:
39+
40+
1. The plugin calls `POST /v1/approvals/{id}/resolve` with `persist: true`
41+
2. Rampart writes a rule to `~/.rampart/policies/auto-allowed.yaml`
42+
3. Future calls matching the same tool + pattern are automatically allowed — you are never asked again
43+
44+
### Fail-open behavior
45+
46+
If `rampart serve` is not running or unreachable, the plugin **fails open** (allows the call) and logs at debug level. This matches Rampart's existing default behavior and keeps OpenClaw functional when Rampart is down.
47+
48+
If `rampart serve` is reachable but returns a 5xx error, the plugin also fails open but logs a warning.
49+
50+
---
51+
52+
## Configuration
53+
54+
Plugin config lives in your OpenClaw config file under `plugins.rampart`:
55+
56+
```yaml
57+
plugins:
58+
rampart:
59+
serveUrl: "http://localhost:9090" # default
60+
enabled: true # default
61+
timeoutMs: 3000 # ms to wait for Rampart before failing open
62+
approvalTimeoutMs: 120000 # ms before unanswered approval auto-denies
63+
```
64+
65+
| Option | Type | Default | Description |
66+
|--------|------|---------|-------------|
67+
| `serveUrl` | string | `http://localhost:9090` | Rampart serve endpoint |
68+
| `enabled` | boolean | `true` | Disable the plugin without uninstalling |
69+
| `timeoutMs` | number | `3000` | Max ms to wait for Rampart before failing open |
70+
| `approvalTimeoutMs` | number | `120000` | Ms before an unanswered approval auto-denies |
71+
72+
---
73+
74+
## Authentication
75+
76+
The plugin reads your Rampart token from (in order):
77+
78+
1. `RAMPART_TOKEN` environment variable
79+
2. `~/.rampart/token` file
80+
81+
This matches the standard Rampart CLI token resolution.
82+
83+
---
84+
85+
## Security note
86+
87+
The plugin only makes network calls to **localhost** (or whatever `serveUrl` is configured to). It reads a single token file from `~/.rampart/token`. It does not phone home, send telemetry, or make any external network requests.
88+
89+
---
90+
91+
## Rampart API contract
92+
93+
The plugin calls:
94+
95+
```
96+
POST http://localhost:9090/v1/tool/{toolName}
97+
Content-Type: application/json
98+
Authorization: Bearer <token>
99+
100+
{
101+
"agent": "main",
102+
"session": "...",
103+
"run_id": "...",
104+
"params": { "command": "ls -la" }
105+
}
106+
```
107+
108+
Expected response shapes:
109+
110+
```json
111+
{ "allowed": true, "decision": "allow", "message": "..." }
112+
{ "allowed": false, "decision": "deny", "message": "blocked by policy X", "policy": "no-rm-rf" }
113+
{ "allowed": false, "decision": "ask", "message": "shell command requires approval", "severity": "warning", "approval_id": "abc123" }
114+
```
115+
116+
When `decision` is `ask`, the plugin uses `approval_id` to call back into Rampart's approval API when the user resolves the prompt:
117+
118+
```
119+
POST http://localhost:9090/v1/approvals/{approval_id}/resolve
120+
{ "approved": true, "resolved_by": "openclaw", "persist": true }
121+
```
122+
123+
The plugin also posts to `POST /v1/audit` after each tool call (best-effort, fire-and-forget).
124+
125+
---
126+
127+
## Replacing the dist-patching approach
128+
129+
Previously, Rampart intercepted OpenClaw tool calls by patching JavaScript files inside OpenClaw's bundled `dist/` directory:
130+
131+
```bash
132+
sudo rampart setup openclaw --patch-tools --force
133+
```
134+
135+
This was fragile — every `openclaw upgrade` would overwrite the patches.
136+
137+
With this plugin installed, you can remove the dist patches:
138+
139+
```bash
140+
# Re-install OpenClaw without the patches (or let an upgrade overwrite them)
141+
# The plugin handles interception through the stable hook API instead.
142+
```
143+
144+
---
145+
146+
## Development
147+
148+
```bash
149+
# Verify no syntax errors (ESM import test)
150+
node -e "import('./index.js').then(() => console.log('ok')).catch(e => console.error(e))"
151+
152+
# Verify manifest JSON
153+
cat openclaw.plugin.json | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{JSON.parse(d); console.log('valid json')})"
154+
```

internal/plugin/openclaw/embed.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright 2026 The Rampart Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
// Package openclaw provides the bundled Rampart OpenClaw plugin.
15+
// The plugin is embedded into the Rampart binary at build time and
16+
// extracted to a temp directory during `rampart setup openclaw --plugin`.
17+
package openclaw
18+
19+
import (
20+
"embed"
21+
"encoding/json"
22+
"fmt"
23+
"os"
24+
"path/filepath"
25+
)
26+
27+
//go:embed index.js package.json openclaw.plugin.json README.md
28+
var PluginFS embed.FS
29+
30+
// Version returns the bundled plugin version from package.json.
31+
func Version() string {
32+
data, err := PluginFS.ReadFile("package.json")
33+
if err != nil {
34+
return "unknown"
35+
}
36+
var pkg struct {
37+
Version string `json:"version"`
38+
}
39+
if err := json.Unmarshal(data, &pkg); err != nil {
40+
return "unknown"
41+
}
42+
return pkg.Version
43+
}
44+
45+
// Extract writes the embedded plugin files to dir and returns the path.
46+
// The caller is responsible for cleanup if the returned path is a temp dir.
47+
func Extract(dir string) error {
48+
files := []string{"index.js", "package.json", "openclaw.plugin.json", "README.md"}
49+
for _, name := range files {
50+
data, err := PluginFS.ReadFile(name)
51+
if err != nil {
52+
return fmt.Errorf("read embedded plugin file %q: %w", name, err)
53+
}
54+
dest := filepath.Join(dir, name)
55+
if err := os.WriteFile(dest, data, 0o644); err != nil {
56+
return fmt.Errorf("write plugin file %q: %w", dest, err)
57+
}
58+
}
59+
return nil
60+
}

0 commit comments

Comments
 (0)