Skip to content

Commit 45b290c

Browse files
feat(sparsekernel): enforce sandbox lease budgets
1 parent d9f13cd commit 45b290c

5 files changed

Lines changed: 203 additions & 34 deletions

File tree

docs/architecture/four-gb-vm-design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,6 @@ Five hundred logical agents are feasible because most are parked in SQLite as co
2323

2424
Book-writing and file-writing agents can run at higher active counts than coding agents because they do not all need browsers, sandboxes, test runners, or heavy model contexts. Expensive work should be scarce, leased, and scheduled.
2525

26-
Resource leases let SparseKernel answer which task owned which expensive resource and when it was released or expired.
26+
Resource leases let SparseKernel answer which task owned which expensive resource and when it was released or expired. Trust-zone budgets are enforced at lease creation for sandbox work: `max_processes` caps active sandbox leases, and `max_runtime_seconds` clamps lease runtime and expiry. This keeps a 4 GB machine from materializing more heavy execution work than its configured trust zone allows.
2727

2828
Browser targets and observations are compact ledger rows, not retained screenshots or traces. This lets small machines keep enough browser provenance to answer which target made a request, emitted console output, or produced an artifact while still pruning old observations with `openclaw runtime prune`.

docs/architecture/local-agent-kernel.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ The broker applies configured trust-zone network policy to explicit allowed orig
108108

109109
## Sandbox broker
110110

111-
The sandbox broker records allocations and leases behind a backend abstraction. Current v0 includes a local/no-isolation backend that can run trusted local commands behind an active sandbox lease for scheduling, timeout, output, usage, and audit accounting. Lease metadata persists the selected backend, isolation description, and trust-zone policy snapshot in SQLite so a restarted broker instance can recover the allocation backend without relying on an in-memory map. The broker can also build command spawn plans for requested bwrap and minijail backends when those binaries are available. Docker is now a policy-backed command backend: it requires a locally available `docker` CLI plus an explicit `dockerImage` or `OPENCLAW_SPARSEKERNEL_DOCKER_IMAGE`, uses `--pull never`, drops capabilities, sets `no-new-privileges`, applies read-only root/tmpfs defaults, maps trust-zone memory/process limits to Docker flags, and keeps networking disabled unless a policy proxy is configured. Set `OPENCLAW_RUNTIME_SANDBOX_REQUIRE_PROXY=1` to fail closed when a network-allowing trust zone lacks a valid loopback `network_policies.proxy_ref`; Docker command plans translate that host loopback proxy to `host.docker.internal` for container workers. This is proxy configuration and allocation gating, not a kernel firewall. SSH, OpenShell, and VM-backed execution remain future wrappers and are not silently executed on the host. In daemon broker mode, embedded runs grant sandbox allocation capability and allocate/release the `code_execution` sandbox lease through the SparseKernel daemon API before falling back to local accounting.
111+
The sandbox broker records allocations and leases behind a backend abstraction. Current v0 includes a local/no-isolation backend that can run trusted local commands behind an active sandbox lease for scheduling, timeout, output, usage, and audit accounting. Lease metadata persists the selected backend, isolation description, and trust-zone policy snapshot in SQLite so a restarted broker instance can recover the allocation backend without relying on an in-memory map. The broker can also build command spawn plans for requested bwrap and minijail backends when those binaries are available. Docker is now a policy-backed command backend: it requires a locally available `docker` CLI plus an explicit `dockerImage` or `OPENCLAW_SPARSEKERNEL_DOCKER_IMAGE`, uses `--pull never`, drops capabilities, sets `no-new-privileges`, applies read-only root/tmpfs defaults, maps trust-zone memory/process limits to Docker flags, and keeps networking disabled unless a policy proxy is configured. Trust-zone `max_processes` now caps active sandbox leases, and `max_runtime_seconds` clamps resource lease runtime and expiry. Set `OPENCLAW_RUNTIME_SANDBOX_REQUIRE_PROXY=1` to fail closed when a network-allowing trust zone lacks a valid loopback `network_policies.proxy_ref`; Docker command plans translate that host loopback proxy to `host.docker.internal` for container workers. This is proxy configuration and allocation gating, not a kernel firewall. SSH, OpenShell, and VM-backed execution remain future wrappers and are not silently executed on the host. In daemon broker mode, embedded runs grant sandbox allocation capability and allocate/release the `code_execution` sandbox lease through the SparseKernel daemon API before falling back to local accounting.
112112

113113
Important boundary: `local/no_isolation` means accounting only. It does not provide process, filesystem, network, kernel, or VM isolation. Docker, bwrap, minijail, gVisor, or VM backends must be described by their actual guarantees when implemented.
114114

src/local-kernel/database.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,58 @@ describe("local runtime kernel database", () => {
476476
});
477477
});
478478

479+
it("enforces trust-zone sandbox budgets and runtime clamps", () => {
480+
const db = openTempDb();
481+
expect(
482+
db.updateTrustZoneLimits({
483+
id: "code_execution",
484+
maxProcesses: 1,
485+
maxRuntimeSeconds: 2,
486+
}),
487+
).toBe(true);
488+
const first = db.createResourceLease({
489+
id: "sandbox-budget-a",
490+
resourceType: "sandbox",
491+
resourceId: "sandbox-budget-a",
492+
trustZoneId: "code_execution",
493+
maxRuntimeMs: 10_000,
494+
leaseUntil: "2030-01-01T00:00:00.000Z",
495+
now: "2026-01-01T00:00:00.000Z",
496+
});
497+
expect(db.getResourceLease(first)).toMatchObject({
498+
maxRuntimeMs: 2_000,
499+
leaseUntil: "2026-01-01T00:00:02.000Z",
500+
});
501+
expect(() =>
502+
db.createResourceLease({
503+
id: "sandbox-budget-b",
504+
resourceType: "sandbox",
505+
resourceId: "sandbox-budget-b",
506+
trustZoneId: "code_execution",
507+
now: "2026-01-01T00:00:01.000Z",
508+
}),
509+
).toThrow(/budget exhausted/);
510+
const audit = db.db
511+
.prepare("SELECT action, payload_json FROM audit_log ORDER BY id DESC LIMIT 1")
512+
.get() as { action: string; payload_json: string };
513+
expect(audit.action).toBe("resource_lease.denied_budget_exhausted");
514+
expect(JSON.parse(audit.payload_json)).toMatchObject({
515+
trustZoneId: "code_execution",
516+
resourceType: "sandbox",
517+
active: 1,
518+
limit: 1,
519+
});
520+
expect(db.releaseResourceLease(first)).toBe(true);
521+
expect(
522+
db.createResourceLease({
523+
id: "sandbox-budget-c",
524+
resourceType: "sandbox",
525+
resourceId: "sandbox-budget-c",
526+
trustZoneId: "code_execution",
527+
}),
528+
).toBe("sandbox-budget-c");
529+
});
530+
479531
it("brokers local/no-isolation sandbox allocations without pretending isolation", () => {
480532
const db = openTempDb();
481533
db.ensureAgent({ id: "main" });

src/local-kernel/database.ts

Lines changed: 147 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,16 @@ type RuntimeInfoRow = {
239239
updated_at: string;
240240
};
241241

242+
class ResourceLeaseBudgetError extends Error {
243+
constructor(
244+
message: string,
245+
readonly payload: Record<string, unknown>,
246+
) {
247+
super(message);
248+
this.name = "ResourceLeaseBudgetError";
249+
}
250+
}
251+
242252
export type OpenLocalKernelDatabaseOptions = {
243253
dbPath?: string;
244254
env?: NodeJS.ProcessEnv;
@@ -526,6 +536,8 @@ export class LocalKernelDatabase {
526536
readonly dbPath: string;
527537
private readonly walMaintenance: SqliteWalMaintenance;
528538
private closed = false;
539+
private transactionDepth = 0;
540+
private savepointCounter = 0;
529541

530542
constructor(options: OpenLocalKernelDatabaseOptions = {}) {
531543
const env = options.env ?? process.env;
@@ -748,7 +760,26 @@ export class LocalKernelDatabase {
748760
}
749761

750762
withTransaction<T>(fn: () => T): T {
763+
if (this.transactionDepth > 0) {
764+
const savepoint = `local_kernel_sp_${++this.savepointCounter}`;
765+
this.db.exec(`SAVEPOINT ${savepoint}`);
766+
this.transactionDepth += 1;
767+
try {
768+
const result = fn();
769+
this.db.exec(`RELEASE SAVEPOINT ${savepoint}`);
770+
return result;
771+
} catch (error) {
772+
try {
773+
this.db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
774+
this.db.exec(`RELEASE SAVEPOINT ${savepoint}`);
775+
} catch {}
776+
throw error;
777+
} finally {
778+
this.transactionDepth -= 1;
779+
}
780+
}
751781
this.db.exec("BEGIN IMMEDIATE");
782+
this.transactionDepth += 1;
752783
try {
753784
const result = fn();
754785
this.db.exec("COMMIT");
@@ -758,6 +789,8 @@ export class LocalKernelDatabase {
758789
this.db.exec("ROLLBACK");
759790
} catch {}
760791
throw error;
792+
} finally {
793+
this.transactionDepth -= 1;
761794
}
762795
}
763796

@@ -1994,37 +2027,120 @@ export class LocalKernelDatabase {
19942027
}): string {
19952028
const id = input.id ?? `lease_${crypto.randomUUID()}`;
19962029
const now = input.now ?? nowIso();
1997-
this.db
1998-
.prepare(
1999-
`INSERT INTO resource_leases(
2000-
id, resource_type, resource_id, owner_task_id, owner_agent_id, trust_zone_id,
2001-
status, lease_until, max_runtime_ms, max_bytes_out, max_tokens, metadata_json, created_at, updated_at
2002-
) VALUES(?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)`,
2003-
)
2004-
.run(
2005-
id,
2006-
input.resourceType,
2007-
input.resourceId,
2008-
input.ownerTaskId ?? null,
2009-
input.ownerAgentId ?? null,
2010-
input.trustZoneId ?? null,
2011-
input.leaseUntil ?? null,
2012-
input.maxRuntimeMs ?? null,
2013-
input.maxBytesOut ?? null,
2014-
input.maxTokens ?? null,
2015-
jsonToText(input.metadata),
2016-
now,
2017-
now,
2018-
);
2019-
this.recordAudit({
2020-
actor: { type: "runtime" },
2021-
action: "resource_lease.created",
2022-
objectType: "resource_lease",
2023-
objectId: id,
2024-
payload: { resourceType: input.resourceType, resourceId: input.resourceId },
2025-
createdAt: now,
2026-
});
2027-
return id;
2030+
try {
2031+
return this.withTransaction(() => {
2032+
const budget = this.resolveResourceLeaseBudget(input, now);
2033+
this.db
2034+
.prepare(
2035+
`INSERT INTO resource_leases(
2036+
id, resource_type, resource_id, owner_task_id, owner_agent_id, trust_zone_id,
2037+
status, lease_until, max_runtime_ms, max_bytes_out, max_tokens, metadata_json, created_at, updated_at
2038+
) VALUES(?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)`,
2039+
)
2040+
.run(
2041+
id,
2042+
input.resourceType,
2043+
input.resourceId,
2044+
input.ownerTaskId ?? null,
2045+
input.ownerAgentId ?? null,
2046+
input.trustZoneId ?? null,
2047+
budget.leaseUntil ?? null,
2048+
budget.maxRuntimeMs ?? null,
2049+
input.maxBytesOut ?? null,
2050+
input.maxTokens ?? null,
2051+
jsonToText(input.metadata),
2052+
now,
2053+
now,
2054+
);
2055+
this.recordAudit({
2056+
actor: { type: "runtime" },
2057+
action: "resource_lease.created",
2058+
objectType: "resource_lease",
2059+
objectId: id,
2060+
payload: {
2061+
resourceType: input.resourceType,
2062+
resourceId: input.resourceId,
2063+
...(input.trustZoneId ? { trustZoneId: input.trustZoneId } : {}),
2064+
...(budget.maxRuntimeMs !== input.maxRuntimeMs
2065+
? { maxRuntimeMs: budget.maxRuntimeMs }
2066+
: {}),
2067+
},
2068+
createdAt: now,
2069+
});
2070+
return id;
2071+
});
2072+
} catch (error) {
2073+
if (error instanceof ResourceLeaseBudgetError) {
2074+
this.recordAudit({
2075+
actor: { type: "runtime" },
2076+
action: "resource_lease.denied_budget_exhausted",
2077+
objectType: "trust_zone",
2078+
objectId: input.trustZoneId,
2079+
payload: error.payload,
2080+
createdAt: now,
2081+
});
2082+
}
2083+
throw error;
2084+
}
2085+
}
2086+
2087+
private resolveResourceLeaseBudget(
2088+
input: {
2089+
resourceType: string;
2090+
trustZoneId?: string;
2091+
leaseUntil?: string;
2092+
maxRuntimeMs?: number;
2093+
},
2094+
now: string,
2095+
): { leaseUntil?: string; maxRuntimeMs?: number } {
2096+
const zone = input.trustZoneId
2097+
? this.listTrustZones().find((entry) => entry.id === input.trustZoneId)
2098+
: undefined;
2099+
if (
2100+
zone?.maxProcesses !== undefined &&
2101+
input.resourceType === "sandbox" &&
2102+
zone.maxProcesses >= 0
2103+
) {
2104+
const row = this.db
2105+
.prepare(
2106+
`SELECT COUNT(*) AS count
2107+
FROM resource_leases
2108+
WHERE trust_zone_id = ? AND resource_type = 'sandbox' AND status = 'active'`,
2109+
)
2110+
.get(zone.id) as CountRow;
2111+
const active = Number(row.count);
2112+
if (active >= zone.maxProcesses) {
2113+
throw new ResourceLeaseBudgetError(
2114+
`Trust zone ${zone.id} sandbox budget exhausted: ${active}/${zone.maxProcesses} active`,
2115+
{
2116+
trustZoneId: zone.id,
2117+
resourceType: input.resourceType,
2118+
active,
2119+
limit: zone.maxProcesses,
2120+
},
2121+
);
2122+
}
2123+
}
2124+
2125+
let maxRuntimeMs = input.maxRuntimeMs;
2126+
if (zone?.maxRuntimeSeconds !== undefined) {
2127+
const trustZoneMaxMs = Math.max(1, Math.trunc(zone.maxRuntimeSeconds * 1000));
2128+
maxRuntimeMs =
2129+
maxRuntimeMs === undefined
2130+
? trustZoneMaxMs
2131+
: Math.min(Math.max(1, Math.trunc(maxRuntimeMs)), trustZoneMaxMs);
2132+
} else if (maxRuntimeMs !== undefined) {
2133+
maxRuntimeMs = Math.max(1, Math.trunc(maxRuntimeMs));
2134+
}
2135+
2136+
let leaseUntil = input.leaseUntil;
2137+
if (maxRuntimeMs !== undefined) {
2138+
const runtimeLeaseUntil = futureIso(now, maxRuntimeMs);
2139+
if (!leaseUntil || Date.parse(leaseUntil) > Date.parse(runtimeLeaseUntil)) {
2140+
leaseUntil = runtimeLeaseUntil;
2141+
}
2142+
}
2143+
return { leaseUntil, maxRuntimeMs };
20282144
}
20292145

20302146
getResourceLease(id: string): ResourceLeaseRecord | undefined {

src/local-kernel/sandbox-broker.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,7 @@ export class LocalSandboxBroker implements SandboxBroker {
572572
policy,
573573
},
574574
});
575+
const lease = this.db.getResourceLease(allocationId);
575576
this.db.recordAudit({
576577
actor: request.agentId ? { type: "agent", id: request.agentId } : { type: "runtime" },
577578
action: "sandbox.allocated",
@@ -593,7 +594,7 @@ export class LocalSandboxBroker implements SandboxBroker {
593594
backend,
594595
status: "active",
595596
createdAt: new Date().toISOString(),
596-
leaseUntil: request.requirements?.leaseUntil,
597+
leaseUntil: lease?.leaseUntil ?? request.requirements?.leaseUntil,
597598
};
598599
}
599600

0 commit comments

Comments
 (0)