Skip to content
Merged
17 changes: 15 additions & 2 deletions nemoclaw-blueprint/policies/openclaw-sandbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,27 @@ network_policies:
tls: terminate
rules:
- 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: POST, path: "/api/*/envelope/**" }
- allow: { method: POST, path: "/api/*/store/**" }
- allow: { method: GET, 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 @@ -142,4 +142,50 @@ describe("base sandbox policy", () => {
}
expect(missingHosts).toEqual([]);
});

// 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);
}
});
});
Loading