Skip to content

Commit 2f3b5b6

Browse files
authored
Merge pull request #16 from peg/staging
v0.1.9: LD_PRELOAD cascade, OpenClaw file tool patching, docs overhaul
2 parents 6ade6f9 + 384064f commit 2f3b5b6

File tree

7 files changed

+310
-11
lines changed

7 files changed

+310
-11
lines changed

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,16 @@ For [OpenClaw](https://github.com/openclaw/openclaw) users — one command sets
498498
rampart setup openclaw
499499
```
500500

501+
This covers all `exec` tool calls. For full file tool coverage (Read, Write, Edit), run:
502+
503+
```bash
504+
rampart setup openclaw --patch-tools
505+
```
506+
507+
This patches OpenClaw's Read, Write, Edit, and Grep tools to check Rampart before file operations. Requires write access to the OpenClaw installation directory (typically needs `sudo` for global npm installs).
508+
509+
⚠️ **Re-run after OpenClaw upgrades** — the patch modifies files in `node_modules` that get replaced on update. Between upgrade and re-patch, file tools bypass Rampart (exec shim remains active).
510+
501511
Works on Linux (systemd) and macOS (launchd).
502512

503513
---
@@ -649,8 +659,9 @@ Current: **v0.1.8** — all tests passing.
649659

650660
| Agent | Method | Status |
651661
|-------|--------|--------|
652-
| Claude Code | `rampart setup claude-code` | Native hooks, all platforms |
653-
| Cline | `rampart setup cline` | Native hooks, all platforms |
662+
| Claude Code | `rampart setup claude-code` | Native hooks (exec + file), all platforms |
663+
| Cline | `rampart setup cline` | Native hooks (exec + file), all platforms |
664+
| OpenClaw | `rampart setup openclaw [--patch-tools]` | Exec shim + optional file tool patch, Linux/macOS |
654665
| Codex CLI | `rampart preload` | LD_PRELOAD, Linux + macOS |
655666
| Claude Desktop | `rampart mcp` | MCP server proxying, all platforms |
656667
| Aider | `rampart wrap` | Linux, macOS |

configs/examples/openclaw.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ policies:
1616
when:
1717
path_matches:
1818
- "**/.ssh/id_*"
19+
path_not_matches:
20+
- "**/*.pub"
21+
- action: deny
22+
when:
23+
path_matches:
1924
- "**/.aws/credentials"
2025
- "**/.gnupg/*"
2126
- "**/*.pem"

docs/THREAT-MODEL.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Threat Model
22

3-
> Last reviewed: 2026-02-12 | Applies to: v0.1.8
3+
> Last reviewed: 2026-02-13 | Applies to: v0.1.9-dev (with file tool patching)
44
55
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.
66

@@ -49,11 +49,13 @@ Policy files are the security boundary. If an attacker can modify policy files,
4949
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.
5050

5151
**Mitigations:**
52+
- **LD_PRELOAD cascade** (v0.1.9+): When using `rampart preload` or `rampart wrap`, child processes spawned by allowed commands automatically inherit the interceptor. This means `python3 script.py` calling `os.system("rm -rf /")` is caught — the subprocess goes through Rampart's policy engine. Covers `execve`, `execvpe`, and `posix_spawn` call paths.
5253
- The optional [rampart-verify](https://github.com/peg/rampart-verify) sidecar uses LLM classification to assess intent of commands like `python3 -c "..."` based on the inline code
53-
- File write hooks can catch the creation of malicious scripts before execution
5454
- Patterns like `python3 -c *` can be routed to semantic verification via `action: webhook`
5555

56-
**Remaining surface:** Multi-step sequences (write file, then execute) are an active area of development. In practice, AI agents overwhelmingly shell out via `subprocess`/`os.system` rather than using native APIs — and those shell-outs go through Rampart.
56+
**Remaining surface:** The 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.
57+
58+
**Multi-step sequences:** With file tool coverage enabled (native hooks or `--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.
5759

5860
### 2. Audit Log Rewrite
5961

@@ -91,7 +93,22 @@ An agent could potentially encode commands to bypass pattern matching:
9193

9294
**Coverage:** The two-layer approach (pattern matching + LLM classification) significantly reduces the obfuscation surface. Pattern matching catches known encodings; the LLM layer catches intent regardless of how the command is formatted.
9395

94-
### 5. Fail-Open Behavior
96+
### 5. Framework-Specific Patching
97+
98+
Some agent frameworks (e.g., OpenClaw) don't expose hook points for file operations. Rampart provides a `--patch-tools` option that modifies framework source files to add policy checks before read/write/edit operations. These patches don't survive framework upgrades — they modify files in `node_modules` that get replaced on update.
99+
100+
**Mitigations:**
101+
- `rampart setup openclaw --patch-tools` must be re-run immediately after OpenClaw upgrades to restore protection
102+
- Native hook integrations (Claude Code, Cline) don't have this limitation — they use the framework's own hook system
103+
- A feature request for generic tool authorization hooks benefits the entire ecosystem and would eliminate the need for patching
104+
105+
**Security implications:**
106+
- **Timing window:** Between OpenClaw upgrade and re-patch, file tools bypass all policies (exec shim remains active)
107+
- **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.
108+
109+
**Trade-off:** Monkey-patching is fragile but functional. It closes a real security gap today while proper upstream 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.
110+
111+
### 6. Fail-Open Behavior
95112

96113
When `rampart serve` is unreachable (crashed, network issue), the shim defaults to **fail-open** — commands execute without policy checks. This is a deliberate design choice: fail-closed would lock you out of your own machine.
97114

policies/standard.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ policies:
5353
when:
5454
path_matches:
5555
- "**/.ssh/id_*"
56+
path_not_matches:
57+
- "**/*.pub"
58+
- action: deny
59+
when:
60+
path_matches:
5661
- "**/.aws/credentials"
5762
- "**/.env"
5863
- "**/.netrc"

preload/librampart.c

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#include <unistd.h>
2424
#include <dlfcn.h>
2525
#include <errno.h>
26+
#include <limits.h>
2627
#include <sys/types.h>
2728
#include <sys/wait.h>
2829
#include <pthread.h>
@@ -51,6 +52,8 @@ struct http_response {
5152
static CURL *curl_handle = NULL;
5253
static pthread_mutex_t curl_mutex = PTHREAD_MUTEX_INITIALIZER;
5354
static pthread_once_t init_once = PTHREAD_ONCE_INIT;
55+
static char preload_lib_path[PATH_MAX];
56+
static int preload_anchor;
5457

5558
// Original function pointers
5659
static int (*real_execve)(const char *, char *const[], char *const[]) = NULL;
@@ -62,6 +65,7 @@ static int (*real_system)(const char *) = NULL;
6265
static FILE *(*real_popen)(const char *, const char *) = NULL;
6366
static int (*real_posix_spawn)(pid_t *, const char *, const posix_spawn_file_actions_t *,
6467
const posix_spawnattr_t *, char *const[], char *const[]) = NULL;
68+
extern char **environ;
6569

6670
static void debug_log(const char *fmt, ...) {
6771
if (!config.debug) return;
@@ -119,6 +123,12 @@ static void init_config(void) {
119123
static void init_library(void) {
120124
init_config();
121125
debug_log("Initializing librampart for PID %d", getpid());
126+
127+
Dl_info info;
128+
if (dladdr(&preload_anchor, &info) && info.dli_fname) {
129+
strncpy(preload_lib_path, info.dli_fname, sizeof(preload_lib_path) - 1);
130+
preload_lib_path[sizeof(preload_lib_path) - 1] = '\0';
131+
}
122132

123133
// Initialize libcurl
124134
curl_global_init(CURL_GLOBAL_DEFAULT);
@@ -175,6 +185,58 @@ static void init_library(void) {
175185
debug_log("Library initialized successfully");
176186
}
177187

188+
static int ld_preload_contains(const char *value) {
189+
if (!value || !*value || !*preload_lib_path) return 0;
190+
size_t n = strlen(preload_lib_path);
191+
const char *p = value;
192+
while (*p) {
193+
const char *end = strchr(p, ':');
194+
size_t len = end ? (size_t)(end - p) : strlen(p);
195+
if (len == n && strncmp(p, preload_lib_path, n) == 0) return 1;
196+
if (!end) break;
197+
p = end + 1;
198+
}
199+
return 0;
200+
}
201+
202+
static void free_modified_envp(char **env) {
203+
if (!env) return;
204+
for (size_t i = 0; env[i]; i++) free(env[i]);
205+
free(env);
206+
}
207+
208+
static char **ensure_preload_env(char *const envp[], int *modified) {
209+
*modified = 0;
210+
if (!*preload_lib_path) return (char **)envp;
211+
char *const *src = envp ? envp : (char *const *)environ;
212+
size_t n = 0, ld_idx = (size_t)-1;
213+
for (; src && src[n]; n++) if (strncmp(src[n], "LD_PRELOAD=", 11) == 0) ld_idx = n;
214+
if (ld_idx != (size_t)-1 && ld_preload_contains(src[ld_idx] + 11)) return (char **)envp;
215+
char **out = calloc(n + 2, sizeof(char *));
216+
if (!out) return (char **)envp;
217+
for (size_t i = 0; i < n; i++) {
218+
if (i == ld_idx) {
219+
const char *cur = src[i] + 11;
220+
size_t cur_len = strlen(cur), lib_len = strlen(preload_lib_path);
221+
out[i] = malloc(11 + cur_len + (cur_len ? 1 : 0) + lib_len + 1);
222+
if (!out[i]) { free_modified_envp(out); return (char **)envp; }
223+
snprintf(out[i], 11 + cur_len + (cur_len ? 1 : 0) + lib_len + 1,
224+
"LD_PRELOAD=%s%s%s", cur, cur_len ? ":" : "", preload_lib_path);
225+
continue;
226+
}
227+
out[i] = strdup(src[i]);
228+
if (!out[i]) { free_modified_envp(out); return (char **)envp; }
229+
}
230+
if (ld_idx == (size_t)-1) {
231+
size_t lib_len = strlen(preload_lib_path);
232+
out[n] = malloc(11 + lib_len + 1);
233+
if (!out[n]) { free_modified_envp(out); return (char **)envp; }
234+
snprintf(out[n], 11 + lib_len + 1, "LD_PRELOAD=%s", preload_lib_path);
235+
}
236+
*modified = 1;
237+
return out;
238+
}
239+
178240
/* Wrap str in quotes with JSON escaping. Returns a malloc'd string.
179241
* Allocation: len*2 (worst case: every char needs a backslash) + 2 (quotes) + 1 (NUL). */
180242
static char *escape_json_string(const char *str) {
@@ -357,7 +419,11 @@ int execve(const char *path, char *const argv[], char *const envp[]) {
357419

358420
debug_log("Allowing execve: %s", cmd ? cmd : "(null)");
359421
if (cmd) free(cmd);
360-
return real_execve(path, argv, envp);
422+
int modified = 0;
423+
char **effective_envp = ensure_preload_env(envp, &modified);
424+
int rc = real_execve(path, argv, effective_envp);
425+
if (modified) free_modified_envp(effective_envp);
426+
return rc;
361427
}
362428

363429
int execvp(const char *file, char *const argv[]) {
@@ -400,7 +466,11 @@ int execvpe(const char *file, char *const argv[], char *const envp[]) {
400466

401467
debug_log("Allowing execvpe: %s", cmd ? cmd : "(null)");
402468
if (cmd) free(cmd);
403-
return real_execvpe(file, argv, envp);
469+
int modified = 0;
470+
char **effective_envp = ensure_preload_env(envp, &modified);
471+
int rc = real_execvpe(file, argv, effective_envp);
472+
if (modified) free_modified_envp(effective_envp);
473+
return rc;
404474
}
405475
#endif
406476

@@ -459,7 +529,11 @@ int posix_spawn(pid_t *pid, const char *path,
459529

460530
debug_log("Allowing posix_spawn: %s", cmd ? cmd : "(null)");
461531
if (cmd) free(cmd);
462-
return real_posix_spawn(pid, path, file_actions, attrp, argv, envp);
532+
int modified = 0;
533+
char **effective_envp = ensure_preload_env(envp, &modified);
534+
int rc = real_posix_spawn(pid, path, file_actions, attrp, argv, effective_envp);
535+
if (modified) free_modified_envp(effective_envp);
536+
return rc;
463537
}
464538

465539
// Cleanup function (called at library unload)
@@ -470,4 +544,4 @@ void cleanup_library(void) {
470544
curl_handle = NULL;
471545
}
472546
curl_global_cleanup();
473-
}
547+
}

preload/test_preload.sh

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,17 @@ test_child_process_inheritance() {
265265
else
266266
log_error "Child process inheritance failed"
267267
fi
268+
269+
export RAMPART_DEBUG="1"
270+
if command -v python3 >/dev/null 2>&1; then
271+
output=$(python3 -c 'import os; os.system("true")' 2>&1 >/dev/null || true)
272+
if echo "$output" | grep -q "Allowing system: true"; then log_success "Python os.system() intercepted"; else log_error "Python os.system() interception missing"; fi
273+
else
274+
log_warning "Skipping Python cascade test (python3 not available)"
275+
fi
276+
output=$(bash -c 'bash -c "ls >/dev/null"' 2>&1 >/dev/null || true)
277+
if echo "$output" | grep -q "Allowing exec"; then log_success "Bash subprocess interception working"; else log_error "Bash subprocess interception missing"; fi
278+
export RAMPART_DEBUG="0"
268279
}
269280

270281
# Main test runner
@@ -341,4 +352,4 @@ case "${1:-}" in
341352
echo "Use --help for usage information"
342353
exit 1
343354
;;
344-
esac
355+
esac

0 commit comments

Comments
 (0)