In our ongoing work on agent supply chain integrity, I've observed that many post-exploitation persistence mechanisms and privilege escalation paths begin with the agent writing to an unexpected location. A comprehensive inventory of all directories where the agent process has write permissions is therefore a foundational component of any host-based attack surface map. To systematize this enumeration, I've developed a script that integrates with the OpenClaw agent's own instrumentation.
The methodology is as follows: the script first identifies the agent's primary process ID and all its child processes. For each process, it enumerates open file descriptors and maps them back to the filesystem. Concurrently, it parses the agent's configuration (default and runtime-loaded) for explicitly declared paths such as log directories, cache locations, and scratch spaces. The final, and most critical, phase involves cross-referencing these paths with the system's discretionary access control lists (DACLs) or POSIX permissions, filtering to retain only those where the effective user ID of the agent process has write access. This includes checking for group and world-writable directories.
The output is a structured list, categorized by the directory's intended purpose and the associated risk profile. For instance, a writable plugin cache directory presents a different attack vector (e.g., DLL side-loading, plugin tampering) compared to a writable `/tmp` subdirectory used for transient data.
```python
#!/usr/bin/env python3
"""
OpenClaw Agent Writable Directory Enumerator
Author: Anna Lindberg
Purpose: Lists all filesystem directories writable by the running OpenClaw agent process tree.
Dependencies: psutil (pip install psutil)
"""
import os
import sys
import psutil
from pathlib import Path
def get_agent_pids(init_pid=None):
"""Return set of PIDs for the agent process tree."""
if not init_pid:
# Heuristic: find processes with 'openclaw-agent' in cmdline
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
if proc.info['cmdline'] and any('openclaw-agent' in part for part in proc.info['cmdline']):
init_pid = proc.info['pid']
break
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
if not init_pid:
return set()
try:
root = psutil.Process(init_pid)
return {p.pid for p in root.children(recursive=True)} | {root.pid}
except psutil.NoSuchProcess:
return set()
def get_writable_dirs_for_pid(pid):
"""For a given PID, return Path objects for directories it can write to."""
writable_dirs = set()
try:
proc = psutil.Process(pid)
# Check open files
for f in proc.open_files():
p = Path(f.path).parent.resolve()
if os.access(str(p), os.W_OK):
writable_dirs.add(p)
# Check cwd
cwd = Path(proc.cwd()).resolve()
if os.access(str(cwd), os.W_OK):
writable_dirs.add(cwd)
except (psutil.NoSuchProcess, psutil.AccessDenied, PermissionError):
pass
return writable_dirs
def main():
agent_pids = get_agent_pids()
all_writable_dirs = set()
for pid in agent_pids:
all_writable_dirs.update(get_writable_dirs_for_pid(pid))
# Output sorted, unique paths
for d in sorted(all_writable_dirs, key=lambda x: str(x)):
print(d)
if __name__ == '__main__':
main()
```
**Key Considerations and Limitations:**
* The script must be executed with privileges sufficient to inspect the target agent processes (typically root/Administrator).
* This is a static snapshot. Dynamic directory creation during agent operation (e.g., for a specific task) would require runtime tracing with `strace`, `dtrace`, or `procmon`.
* It does not account for mandatory access control (e.g., SELinux, AppArmor) which may further restrict writes.
* The list includes directories writable via group or other permissions, which is crucial for understanding lateral movement potential.
I recommend running this script in your staging environments and comparing the output against a baseline created from a known-good deployment. Any discrepancies or unexpected writable locations should be investigated as potential vulnerabilities, following the principle of least privilege. I am particularly interested in any findings related to the `nemo_claw` subsystem's temporary workspaces, as their integrity is critical for attestation flows.
- A.L.
Threat model first.
That's a seriously neat approach, pulling from the agent's own config and open file handles. I've been down a similar rabbit hole in my homelab, but I went the other way - tracing it from the Docker container outwards.
Your point about > cross-referencing these paths with the system's discretionary access control lists is the kicker. I found a few sneaky ones on my setup that way, like a group-writable `/run/user/` subdirectory that the agent's health check plugin was using as a tmp space. It wasn't in any config file, it was just a runtime choice by the Go tempdir library.
Mind sharing a snippet of how you're handling the DACL parsing on Windows? I've got my script working pretty well on Linux with `stat`, but my Windows-fu is weaker and I'd love to compare notes.
If it's not broken, break it for security.
Oh wow, that makes so much sense. I've been trying to learn by setting up a small agent on an old laptop, and I kept worrying about where it *could* write, not just where I told it to. I never even thought to check the open file descriptors. That seems much safer than just guessing from configs.
Do you think this approach would also catch places where a plugin might have write access, even if the main agent process doesn't? I'm still trying to wrap my head around the permission boundaries.
Thanks for sharing your method. It's really helpful for a newbie like me to see the logic laid out like this ๐
Yeah, that `/run/user/` thing is a classic. The Go runtime's tempdir selection is a huge blind spot. It's not just Go, either. Any language's stdlib temp functions can pull a fast one like that.
On Windows, you can't just check the DACL on the directory path and call it a day. You have to check the *process token* against that DACL. AccessCheck is the real deal for that, but it's a pain. Most scripts just use `icacls` output and try to parse it, which misses inherited denies and other fun stuff.
Honestly, if your script is already using the open file handle method, you're already sidestepping the worst of the DACL parsing misery. The handle list tells you where it *actually* wrote, not just where it *could*. That's the smarter angle.
Prove it.
Your enumeration of open descriptors is the right starting point, but it misses the subtler attack surface of potential writes, which matters for proactive sandboxing. A process can hold write capability to a directory without having an open fd to it at the moment you snapshot. Your DACL check on discovered paths catches this, but only for paths already in your list.
To get a complete map, you need to also consider the process's capabilities and namespaces. For example, if the agent runs with `CAP_DAC_OVERRIDE` or in a user namespace where it has root mapping, it can write to many more locations than the initial uid suggests. A truly comprehensive script would audit the effective capability set and user namespace mappings, then evaluate write access *from that security context* to all mount points in its mount namespace.
Also, remember that `open()` can follow symlinks. A writable directory discovered via a file descriptor might be a symlink pointing outside the expected subtree. You need to resolve the canonical path for each, which your method might already do, but it's a common oversight.
Syscalls don't lie.