Skip to content

Commit ecdbf55

Browse files
committed
feat(sparsekernel): harden broker boundaries
1 parent ba47e81 commit ecdbf55

22 files changed

Lines changed: 442 additions & 28 deletions

crates/sparsekernel-cli/src/lib.rs

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,42 @@ fn base64_artifact_compat_disabled() -> bool {
431431
)
432432
}
433433

434+
fn base64_artifact_compat_max_bytes() -> Option<usize> {
435+
let raw = env::var("SPARSEKERNEL_ARTIFACT_BASE64_MAX_BYTES").ok();
436+
let Some(value) = raw
437+
.as_deref()
438+
.map(str::trim)
439+
.filter(|value| !value.is_empty())
440+
else {
441+
return Some(1024 * 1024);
442+
};
443+
let normalized = value.to_ascii_lowercase();
444+
if matches!(normalized.as_str(), "unlimited" | "none" | "off") {
445+
return None;
446+
}
447+
value
448+
.parse::<usize>()
449+
.ok()
450+
.map(Some)
451+
.unwrap_or(Some(1024 * 1024))
452+
}
453+
454+
fn enforce_base64_artifact_compat_size(
455+
bytes_len: usize,
456+
operation: &str,
457+
) -> Result<(), Box<dyn Error>> {
458+
let Some(max_bytes) = base64_artifact_compat_max_bytes() else {
459+
return Ok(());
460+
};
461+
if bytes_len <= max_bytes {
462+
return Ok(());
463+
}
464+
Err(format!(
465+
"base64 artifact {operation} is limited to {max_bytes} bytes; use /artifacts/import-file or /artifacts/export-file"
466+
)
467+
.into())
468+
}
469+
434470
fn canonical_child_path(root: &Path, raw_path: &str) -> Result<PathBuf, Box<dyn Error>> {
435471
fs::create_dir_all(root)?;
436472
let canonical_root = root.canonicalize()?;
@@ -856,6 +892,9 @@ pub fn handle_api_request_with_artifact_root(
856892
);
857893
}
858894
let bytes = decode_artifact_content(&input)?;
895+
if input.content_base64.is_some() {
896+
enforce_base64_artifact_compat_size(bytes.len(), "create")?;
897+
}
859898
if let Some(subject) = &input.subject {
860899
require_artifact_capability(db, subject, None, "write")?;
861900
}
@@ -928,11 +967,13 @@ pub fn handle_api_request_with_artifact_root(
928967
subject.permission.as_deref().unwrap_or("read"),
929968
)
930969
});
970+
let artifact = db.get_artifact(&input.id)?;
971+
enforce_base64_artifact_compat_size(artifact.size_bytes as usize, "read")?;
931972
let bytes = store.read(&input.id, subject)?;
932973
ApiReply {
933974
status_code: 200,
934975
body: json!({
935-
"artifact": db.get_artifact(&input.id)?,
976+
"artifact": artifact,
936977
"content_base64": BASE64_STANDARD.encode(bytes),
937978
}),
938979
}
@@ -1744,4 +1785,71 @@ mod tests {
17441785
.to_string()
17451786
.contains("base64 artifact read is disabled"));
17461787
}
1788+
1789+
#[test]
1790+
fn artifact_api_limits_base64_compatibility_size() {
1791+
let _guard = env_lock();
1792+
let previous_limit = env::var("SPARSEKERNEL_ARTIFACT_BASE64_MAX_BYTES").ok();
1793+
let previous_disable = env::var("SPARSEKERNEL_DISABLE_BASE64_ARTIFACTS").ok();
1794+
let previous_mode = env::var("SPARSEKERNEL_ARTIFACT_BASE64").ok();
1795+
env::set_var("SPARSEKERNEL_ARTIFACT_BASE64_MAX_BYTES", "4");
1796+
env::remove_var("SPARSEKERNEL_DISABLE_BASE64_ARTIFACTS");
1797+
env::remove_var("SPARSEKERNEL_ARTIFACT_BASE64");
1798+
let mut db = SparseKernelDb::open(":memory:").unwrap();
1799+
let root = tempfile::tempdir().unwrap();
1800+
let create = handle_api_request_with_artifact_root(
1801+
&mut db,
1802+
"POST",
1803+
"/artifacts/create",
1804+
serde_json::to_string(&json!({
1805+
"content_base64": BASE64_STANDARD.encode(b"too large"),
1806+
}))
1807+
.unwrap()
1808+
.as_bytes(),
1809+
Some(root.path()),
1810+
);
1811+
let text = handle_api_request_with_artifact_root(
1812+
&mut db,
1813+
"POST",
1814+
"/artifacts/create",
1815+
serde_json::to_string(&json!({
1816+
"content_text": "too large for base64",
1817+
"mime_type": "text/plain",
1818+
}))
1819+
.unwrap()
1820+
.as_bytes(),
1821+
Some(root.path()),
1822+
)
1823+
.unwrap()
1824+
.body;
1825+
let read = handle_api_request_with_artifact_root(
1826+
&mut db,
1827+
"POST",
1828+
"/artifacts/read",
1829+
serde_json::to_string(&json!({ "id": text["id"] }))
1830+
.unwrap()
1831+
.as_bytes(),
1832+
Some(root.path()),
1833+
);
1834+
match previous_limit {
1835+
Some(value) => env::set_var("SPARSEKERNEL_ARTIFACT_BASE64_MAX_BYTES", value),
1836+
None => env::remove_var("SPARSEKERNEL_ARTIFACT_BASE64_MAX_BYTES"),
1837+
}
1838+
match previous_disable {
1839+
Some(value) => env::set_var("SPARSEKERNEL_DISABLE_BASE64_ARTIFACTS", value),
1840+
None => env::remove_var("SPARSEKERNEL_DISABLE_BASE64_ARTIFACTS"),
1841+
}
1842+
match previous_mode {
1843+
Some(value) => env::set_var("SPARSEKERNEL_ARTIFACT_BASE64", value),
1844+
None => env::remove_var("SPARSEKERNEL_ARTIFACT_BASE64"),
1845+
}
1846+
assert!(create
1847+
.unwrap_err()
1848+
.to_string()
1849+
.contains("base64 artifact create is limited"));
1850+
assert!(read
1851+
.unwrap_err()
1852+
.to_string()
1853+
.contains("base64 artifact read is limited"));
1854+
}
17471855
}

crates/sparsekernel-core/src/lib.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2398,7 +2398,18 @@ fn run_hard_egress_helper(
23982398
.ok()
23992399
.map(|value| value.trim().to_string())
24002400
.filter(|value| !value.is_empty())
2401+
.or_else(|| {
2402+
enforcement
2403+
.and_then(|value| value.get("helper"))
2404+
.and_then(Value::as_str)
2405+
.map(str::trim)
2406+
.filter(|value| !value.is_empty())
2407+
.map(str::to_string)
2408+
})
24012409
.ok_or_else(|| SparseKernelError::Denied("missing hard egress helper".to_string()))?;
2410+
if is_builtin_hard_egress_helper(&helper) {
2411+
return run_builtin_hard_egress_helper(action, allocation_id, backend);
2412+
}
24022413
let payload = json!({
24032414
"protocol": "openclaw.sparsekernel.sandbox-egress.v1",
24042415
"action": action,
@@ -2475,6 +2486,37 @@ fn run_hard_egress_helper(
24752486
})))
24762487
}
24772488

2489+
fn is_builtin_hard_egress_helper(helper: &str) -> bool {
2490+
matches!(
2491+
helper.trim().to_ascii_lowercase().as_str(),
2492+
"builtin" | "sparsekernel:builtin" | "openclaw:sparsekernel:builtin"
2493+
)
2494+
}
2495+
2496+
fn run_builtin_hard_egress_helper(
2497+
action: &str,
2498+
allocation_id: &str,
2499+
backend: &str,
2500+
) -> Result<Option<Value>> {
2501+
if action == "release" {
2502+
return Ok(None);
2503+
}
2504+
let description = match backend {
2505+
"bwrap" => "bubblewrap --unshare-all selected by the SparseKernel sandbox backend",
2506+
_ => {
2507+
return Err(SparseKernelError::Denied(format!(
2508+
"builtin hard egress only supports daemon-owned bwrap allocations, got {backend}"
2509+
)));
2510+
}
2511+
};
2512+
Ok(Some(json!({
2513+
"helper": "builtin",
2514+
"enforcementId": format!("builtin:{backend}:{allocation_id}"),
2515+
"boundary": "platform_enforcer",
2516+
"description": description,
2517+
})))
2518+
}
2519+
24782520
impl SandboxBroker for LocalSandboxBroker<'_> {
24792521
fn allocate_sandbox(
24802522
&self,
@@ -3124,4 +3166,49 @@ mod tests {
31243166
.any(|entry| entry.action == "network_policy.hard_egress_enforced"));
31253167
assert!(release_result.unwrap().unwrap());
31263168
}
3169+
3170+
#[test]
3171+
fn sandbox_builtin_hard_egress_allows_backend_enforced_allocations() {
3172+
let _guard = env_lock();
3173+
let (_dir, db) = temp_db();
3174+
let previous = env::var("OPENCLAW_RUNTIME_SANDBOX_REQUIRE_HARD_EGRESS").ok();
3175+
let previous_helper = env::var("OPENCLAW_RUNTIME_SANDBOX_HARD_EGRESS_HELPER").ok();
3176+
let previous_helper_args =
3177+
env::var("OPENCLAW_RUNTIME_SANDBOX_HARD_EGRESS_HELPER_ARGS").ok();
3178+
env::set_var("OPENCLAW_RUNTIME_SANDBOX_REQUIRE_HARD_EGRESS", "1");
3179+
env::set_var("OPENCLAW_RUNTIME_SANDBOX_HARD_EGRESS_HELPER", "builtin");
3180+
env::remove_var("OPENCLAW_RUNTIME_SANDBOX_HARD_EGRESS_HELPER_ARGS");
3181+
let sandbox = LocalSandboxBroker { db: &db };
3182+
let allocation = sandbox.allocate_sandbox(None, None, "public_web", Some("bwrap"));
3183+
let release_result = allocation
3184+
.as_ref()
3185+
.ok()
3186+
.map(|allocation| sandbox.release_sandbox(&allocation.id));
3187+
let audit = db.list_audit(5).unwrap();
3188+
match previous {
3189+
Some(value) => env::set_var("OPENCLAW_RUNTIME_SANDBOX_REQUIRE_HARD_EGRESS", value),
3190+
None => env::remove_var("OPENCLAW_RUNTIME_SANDBOX_REQUIRE_HARD_EGRESS"),
3191+
}
3192+
match previous_helper {
3193+
Some(value) => env::set_var("OPENCLAW_RUNTIME_SANDBOX_HARD_EGRESS_HELPER", value),
3194+
None => env::remove_var("OPENCLAW_RUNTIME_SANDBOX_HARD_EGRESS_HELPER"),
3195+
}
3196+
match previous_helper_args {
3197+
Some(value) => env::set_var("OPENCLAW_RUNTIME_SANDBOX_HARD_EGRESS_HELPER_ARGS", value),
3198+
None => env::remove_var("OPENCLAW_RUNTIME_SANDBOX_HARD_EGRESS_HELPER_ARGS"),
3199+
}
3200+
let allocation = allocation.unwrap();
3201+
assert_eq!(allocation.backend, "bwrap");
3202+
assert!(audit.iter().any(|entry| {
3203+
entry.action == "network_policy.hard_egress_enforced"
3204+
&& entry
3205+
.payload
3206+
.as_ref()
3207+
.and_then(|payload| payload.get("enforcement"))
3208+
.and_then(|enforcement| enforcement.get("boundary"))
3209+
.and_then(Value::as_str)
3210+
== Some("platform_enforcer")
3211+
}));
3212+
assert!(release_result.unwrap().unwrap());
3213+
}
31273214
}

docs/architecture/artifact-store.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,6 @@ Retention policies:
2626

2727
Artifact reads and writes are capability-mediated where the caller is not the trusted runtime. Access grants are recorded in `artifact_access`.
2828

29-
The local artifact store accepts bytes, streams, or file paths. File imports stream into a private temporary blob while computing sha256, then move into the content-addressed path, so downloads and snapshots do not need to be loaded fully into memory. The v0 `sparsekerneld` API exposes artifact create/read/metadata endpoints over local JSON. For small compatibility payloads, binary content can still be transported as base64. Hardened local deployments can set `SPARSEKERNEL_DISABLE_BASE64_ARTIFACTS=1` or `SPARSEKERNEL_ARTIFACT_BASE64=off` to reject base64 create/read calls and require staged file transfer instead. For large local payloads, use `/artifacts/import-file` with a file staged under the daemon-owned staging directory and `/artifacts/export-file` to copy content into the daemon-owned export directory without moving bytes through JSON. Node clients can use `@openclaw/sparsekernel-client/node-artifacts` to resolve the daemon-compatible staging directory, copy local files into it, and copy exported files to a caller destination without touching the base64 compatibility path.
29+
The local artifact store accepts bytes, streams, or file paths. File imports stream into a private temporary blob while computing sha256, then move into the content-addressed path, so downloads and snapshots do not need to be loaded fully into memory. The v0 `sparsekerneld` API exposes artifact create/read/metadata endpoints over local JSON. For small compatibility payloads, binary content can still be transported as base64, capped by `SPARSEKERNEL_ARTIFACT_BASE64_MAX_BYTES` and defaulting to 1 MiB. Hardened local deployments can set `SPARSEKERNEL_DISABLE_BASE64_ARTIFACTS=1` or `SPARSEKERNEL_ARTIFACT_BASE64=off` to reject base64 create/read calls and require staged file transfer instead; `SPARSEKERNEL_ARTIFACT_BASE64_MAX_BYTES=unlimited` restores uncapped compatibility when an operator explicitly accepts JSON transport. For large local payloads, use `/artifacts/import-file` with a file staged under the daemon-owned staging directory and `/artifacts/export-file` to copy content into the daemon-owned export directory without moving bytes through JSON. Node clients can use `@openclaw/sparsekernel-client/node-artifacts` to resolve the daemon-compatible staging directory, copy local files into it, and copy exported files to a caller destination without touching the base64 compatibility path.
3030

3131
Browser broker adapters must route screenshots and downloads through this API. The TypeScript CDP broker uses staged local imports when the kernel client supports `/artifacts/import-file`, then falls back to base64 artifact create only for compatibility clients. The OpenClaw browser proxy exports screenshot/PDF artifacts back to local temp files through `/artifacts/export-file` instead of reading base64 payloads. Agents should receive artifact ids and metadata, not raw browser download paths or large binary payloads.

docs/architecture/browser-broker.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Set `OPENCLAW_RUNTIME_BROWSER_BROKER=native` to let SparseKernel launch and supe
2121

2222
Set `OPENCLAW_RUNTIME_BROWSER_REQUIRE_PROXY=1` when a trust zone must use a proxy-backed browser egress path. The trust zone's network policy must contain a loopback `proxy_ref`, and native browser pools launch Chromium with `--proxy-server=<proxy_ref>`. Static or externally managed CDP endpoints are rejected in this mode unless `OPENCLAW_RUNTIME_BROWSER_EXTERNAL_PROXY_OK=1` asserts that the external browser process is already proxy-controlled. This protects the SparseKernel-owned browser process path; it is not host-level egress enforcement for arbitrary host processes.
2323

24-
Supported v0 actions (`status`, `doctor`, `profiles`, `tabs`, `open`, `navigate`, `focus`, `close`, `snapshot`, `console`, `screenshot`, `pdf`, direct file-input `upload`, `dialog`, and brokered `act`) operate against broker-owned targets inside the leased CDP context. Brokered `act` covers the OpenClaw action contract for click, coordinate click, type, press, hover, scroll, drag, select, fill, resize, wait, evaluate, close, and batch using CDP input events plus bounded DOM evaluation. Selector-backed actions retry inside the leased page until their action timeout and now require basic actionability before dispatch: visible connected target, stable bounding box, enabled form state where relevant, editable target for typing, and center-point hit testing. Selector click and hover resolve an actionable center point in the leased page and dispatch real CDP mouse events rather than handing raw DOM click events to page code; click and coordinate-click modifiers are translated to CDP input bitmasks. `wait --load networkidle` uses per-target CDP Network events plus a quiet window rather than only checking `document.readyState`. Actions that can change page state are followed by a broker-side navigation check: same-target navigations are accepted only when the resulting URL stays inside the context's allowed-origin policy, same-policy popups are attached as broker-owned targets, and disallowed popups are closed. When an allowed-origin policy is configured, the broker also enables CDP Fetch interception and fails requests outside that policy while recording `browser_network.blocked` observations; this is request control, not host isolation. Before opening or navigating, the ToolBroker checks the trust-zone network policy and denies unsupported schemes, private-network destinations when disallowed, literal denied CIDRs, and, when runtime policy enforcement is enabled, hostnames that resolve to denied/private addresses. Host-level browser egress control remains outside the broker unless the configured browser process or host platform provides it. Snapshots use a bounded CDP `Runtime.evaluate` DOM read, actions resolve refs from the latest brokered snapshot where needed, console output is captured from CDP runtime/log events per target, and screenshot/PDF output is captured as SparseKernel artifacts, read back through artifact access, and converted to existing tool result formats for compatibility. Closing a broker-owned target now closes that target; the full browser context is released only when the last target closes or broker cleanup runs.
24+
Supported v0 actions (`status`, `doctor`, `profiles`, `tabs`, `open`, `navigate`, `focus`, `close`, `snapshot`, `console`, `screenshot`, `pdf`, direct file-input `upload`, `dialog`, and brokered `act`) operate against broker-owned targets inside the leased CDP context. Brokered `act` covers the OpenClaw action contract for click, coordinate click, type, press, hover, scroll, drag, select, fill, resize, wait, evaluate, close, and batch using CDP input events plus bounded DOM evaluation. Selector-backed actions retry inside the leased page until their action timeout and now require basic actionability before dispatch: visible connected target, stable bounding box, enabled form state where relevant, editable target for typing, and center-point hit testing. Selector click and hover resolve an actionable center point in the leased page and dispatch real CDP mouse events rather than handing raw DOM click events to page code; click, coordinate-click, and press modifiers are translated to CDP input bitmasks. `wait --load networkidle` uses per-target CDP Network events plus a quiet window rather than only checking `document.readyState`. Actions that can change page state are followed by a broker-side navigation check: same-target navigations are accepted only when the resulting URL stays inside the context's allowed-origin policy, same-policy popups are attached as broker-owned targets, and disallowed popups are closed. When an allowed-origin policy is configured, the broker also enables CDP Fetch interception and fails requests outside that policy while recording `browser_network.blocked` observations; this is request control, not host isolation. Before opening or navigating, the ToolBroker checks the trust-zone network policy and denies unsupported schemes, private-network destinations when disallowed, literal denied CIDRs, and, when runtime policy enforcement is enabled, hostnames that resolve to denied/private addresses. Host-level browser egress control remains outside the broker unless the configured browser process or host platform provides it. Snapshots use a bounded CDP `Runtime.evaluate` DOM read, actions resolve refs from the latest brokered snapshot where needed, console output is captured from CDP runtime/log events per target, and screenshot/PDF output is captured as SparseKernel artifacts, read back through artifact access, and converted to existing tool result formats for compatibility. Closing a broker-owned target now closes that target; the full browser context is released only when the last target closes or broker cleanup runs.
2525

2626
Use `openclaw sparsekernel browser-pools` to inspect durable ledger pools and currently materialized native browser process pools. Native pool snapshots include trust zone, profile, active refs, max context slots, idle timeout, endpoint, PID when available, last activity, start count, clean stop count, and crash count.
2727

0 commit comments

Comments
 (0)