Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion nemoclaw-blueprint/policies/openclaw-sandbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,27 @@ network_policies:
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: POST, path: "/**" }
# sentry.io is a multi-tenant SaaS — any authenticated client can POST
# to ANY Sentry project, not just NemoClaw's. Allowing POST /** turned
# the host into a generic exfiltration channel: a compromised agent
# could ship stack traces, env vars, file contents, etc. to a Sentry
# project controlled by an attacker via the public envelope endpoint
# (https://sentry.io/api/<any-project>/envelope/). Path-pattern
# restrictions cannot fix this because the project ID is part of the
# URL and there is no server-side allowlist of legitimate projects.
#
# Block POST entirely. GET stays allowed because it has no request
# body and is harmless for exfil. Side effect: Claude Code's crash
# telemetry to Sentry is silently dropped — that is the right
# tradeoff for a sandbox whose stated goal is preventing data egress.
# See #1437.
- host: sentry.io
port: 443
protocol: rest
enforcement: enforce
tls: terminate
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: POST, path: "/**" }
binaries:
- { path: /usr/local/bin/claude }

Expand Down
46 changes: 46 additions & 0 deletions test/validate-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,50 @@ describe("base sandbox policy", () => {
it("has 'network_policies'", () => {
expect("network_policies" in policy).toBe(true);
});

// Walk every endpoint in every network_policies entry and return the
// entries whose host matches `hostMatcher`. Used by the regressions below.
type Rule = { allow?: { method?: string; path?: string } };
type Endpoint = { host?: string; rules?: Rule[] };
function findEndpoints(hostMatcher: (h: string) => boolean): Endpoint[] {
const out: Endpoint[] = [];
const np = (policy as Record<string, unknown>).network_policies;
if (!np || typeof np !== "object") return out;
for (const value of Object.values(np as Record<string, unknown>)) {
if (!value || typeof value !== "object") continue;
const endpoints = (value as { endpoints?: unknown }).endpoints;
if (!Array.isArray(endpoints)) continue;
for (const ep of endpoints) {
if (ep && typeof ep === "object" && typeof (ep as Endpoint).host === "string") {
if (hostMatcher((ep as Endpoint).host as string)) {
out.push(ep as Endpoint);
}
}
}
}
return out;
}

it("regression #1437: sentry.io has no POST allow rule (multi-tenant exfiltration vector)", () => {
const sentryEndpoints = findEndpoints((h) => h === "sentry.io");
expect(sentryEndpoints.length).toBeGreaterThan(0); // should still appear
for (const ep of sentryEndpoints) {
const rules = Array.isArray(ep.rules) ? ep.rules : [];
const hasPost = rules.some(
(r) => r && r.allow && typeof r.allow.method === "string" && r.allow.method.toUpperCase() === "POST",
);
expect(hasPost).toBe(false);
}
});

it("regression #1437: sentry.io retains GET (harmless, no body for exfil)", () => {
const sentryEndpoints = findEndpoints((h) => h === "sentry.io");
for (const ep of sentryEndpoints) {
const rules = Array.isArray(ep.rules) ? ep.rules : [];
const hasGet = rules.some(
(r) => r && r.allow && typeof r.allow.method === "string" && r.allow.method.toUpperCase() === "GET",
);
expect(hasGet).toBe(true);
}
});
});