SandboxNetwork egress rules

Network egress rules

Set the sandbox’s egress policy at create or while it runs. The sandbox-side proxy enforces it, backed by the host firewall; activity reports what it reached.

ModeThe sandbox can reach
openAny host. Ask for it with egressPolicy: { mode: "open" }.
strictThe hosts in allowDomains, plus the model endpoint and providers the platform provisioned into the sandbox. Nothing else: no package registry, no source host, unless you list it.
blockedNo outbound internet.

Every mode, blocked included, reaches Tangle’s control plane without listing it: the Sandbox API (sandbox.tangle.tools) and its exec origins, Router inference, and Platform’s Hub and workflow routes. A box can create sibling sandboxes or publish a preview link under any policy.

A sandbox created without a policy reaches package registries (npm, Yarn, PyPI, crates.io, RubyGems, Go), GitHub, GitLab, Bitbucket, container registries and model endpoints, and nothing else. A team sandbox created without a policy inherits the team’s default policy.

Lock a build to the hosts it needs

This sandbox can install npm packages and clone from GitHub, and nothing else.

import { Sandbox } from "@tangle-network/sandbox";
 
const client = new Sandbox({
  apiKey: process.env.TANGLE_API_KEY!,
  baseUrl: "https://sandbox.tangle.tools",
});
 
const box = await client.create({
  name: "locked-build",
  egressPolicy: {
    mode: "strict",
    allowDomains: ["registry.npmjs.org", "github.com"],
  },
  maxLifetimeSeconds: 3600,
});
 
try {
  await box.waitFor("running");
  await box.exec("git clone --depth 1 https://github.com/vercel/ms ms");
  const build = await box.exec("cd ms && npm install && npm test", {
    timeoutMs: 5 * 60_000,
  });
  console.log(build.exitCode, build.stdout.slice(-400));
} finally {
  await box.delete();
}

For a host that resolves to a private or CGNAT address, declare its permitted CIDRs explicitly. String entries keep the resolved-IP deny guard. An exception applies only to its named domain and the exact authored ranges; it does not open other domains at the same address. Prefer the smallest range the service needs.

await box.egress.update({ mode: "strict", allowDomains: [
  "api.github.com",
  { domain: "build.internal", allowPrivateIps: ["10.203.1.0/24"] },
] });

Discover the allowlist from denials

A refused request gets a 403 whose JSON body names the cause:

{"code":"EGRESS_DOMAIN_NOT_ALLOWED","host":"github.com","policy":"allowlist","nextStep":"Add github.com to egressPolicy.allowDomains …"}

policy is the proxy rule that refused it (allowlist, or secrets for a credential the proxy would not redeem), and matches rejectedBy in box.egress.denials(), which lists every refused host and how often. Start strict with nothing allowed, run the build once, and read the refusals:

const box = await client.create({
  egressPolicy: { mode: "strict", allowDomains: [] },
});
await box.waitFor("running");
await box.exec(
  "git clone --depth 1 https://github.com/vercel/ms ms && cd ms && npm ci",
);
 
for (const denial of await box.egress.denials({ limit: 50 })) {
  console.log(`${denial.host}  refused ${denial.count}x by ${denial.rejectedBy}`);
}
// github.com  refused 1x by allowlist
// registry.npmjs.org  refused 3x by allowlist

Add the hosts you trust and apply them to the running sandbox:

await box.egress.update({
  mode: "strict",
  allowDomains: ["github.com", "codeload.github.com", "registry.npmjs.org"],
});

update() restarts the sandbox’s proxy to apply the policy, which interrupts outbound connections for a few seconds. The restart also starts a new activity window, so read denials() and activity() before you change the policy.

Install, then cut the network

Untrusted code should not run with network access. Install dependencies under a strict policy, then switch to blocked before the code runs:

await box.exec("cd app && npm ci");
await box.egress.update({ mode: "blocked" });
const result = await box.exec("cd app && npm test");

Prove what a sandbox reached

denials() is best effort: an empty list can mean nothing was refused or that the proxy log could not be read. When you need evidence, for an audit or a benchmark control arm, use activity(). It lists allowed and refused hosts and says whether the record is complete.

const activity = await box.egress.activity({ limit: 500 });
if (!activity.complete) {
  throw new Error(`incomplete record: ${activity.incompleteReasons.join(", ")}`);
}
for (const destination of activity.destinations) {
  console.log(destination.action, destination.host, destination.count);
}

An empty destinations list with complete: true is evidence the sandbox reached nothing. With complete: false it proves nothing.

Domain patterns

PatternMatches
api.github.comThat host only.
*.githubusercontent.comgithubusercontent.com and every subdomain at any depth.
**.githubusercontent.comSubdomains only, not githubusercontent.com itself.

includeImplicitDomains: true merges the platform’s default developer list into a strict policy, including github.com, gist.github.com, and gitlab.com. That makes any public repository readable and a gist an allowed destination, so use it only for trusted development sandboxes.

Keep a token out of the sandbox

Bind the host when storing or selecting a secret. The sandbox receives a placeholder; the proxy holds the value and adds it only to that host’s requests. Raw delivery requires the explicit exposeInBox: true option.

await client.secrets.create("HF_TOKEN", process.env.HF_TOKEN!);
 
const box = await client.create({
  secrets: [{ name: "HF_TOKEN", host: "huggingface.co" }],
});
 
// HF_TOKEN contains a placeholder. Only a matching HTTPS request redeems it.
await box.exec(
  `curl -s -H "Authorization: Bearer $HF_TOKEN" https://huggingface.co/api/whoami-v2`,
);

From the CLI, tangle sandbox create --secret [email protected] does the same, and NAME@host:Header names a header other than Authorization.

  • host is one public DNS name: no scheme, port, path, wildcard or IP address. A Tangle host is refused.
  • header is where the sandbox puts the placeholder. The default, Authorization, covers bearer, token and basic auth. Name another header, for example X-Api-Key, for an API that takes the key there.
  • A strict policy does not reach the host for you. Add it to allowDomains when the sandbox needs to call it.
  • Create refuses when the host cannot hold the value (DECLARED_SECRET_HOST_UNSUPPORTED). It never falls back to exposing the value in the sandbox.
  • The proxy swaps the value into HTTPS requests only. A plaintext http:// request to the host carries the placeholder and does not authenticate.
  • The proxy masks reflected values in HTTP headers, trailers, bodies and supported WebSocket message streams, including Basic credential base64. It requests plain or gzip responses and refuses unsupported compression with 502. Arbitrary encodings such as URL encoding or hex cannot be scrubbed; bind trusted hosts.
  • The sandbox holds a fixed-shape placeholder, so a tool that checks a token’s shape before it sends it (an hf_ prefix) rejects it, and a credential that signs the request, such as AWS SigV4, cannot be held this way.
  • createReceipt.injectedSecrets still lists the name, because the create decrypted it. The sandbox reads only the placeholder.

Cloud agents behind a strict policy

A coding agent’s model calls go to the endpoint provisioned into the sandbox, which strict mode allows. When the agent uses your own subscription or a coding plan endpoint, list that provider’s host so the policy states it explicitly:

egressPolicy: {
  mode: "strict",
  allowDomains: ["api.anthropic.com", "registry.npmjs.org", "github.com"],
}

See Start a cloud agent for the agent side.

From HTTP or the CLI

The CLI has no egress flag yet. Use the HTTP API, which merges the body into the current policy:

curl -X PATCH "https://sandbox.tangle.tools/v1/sandboxes/$SANDBOX_ID/egress" \
  -H "Authorization: Bearer $TANGLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode":"strict","allowDomains":["registry.npmjs.org"]}'
RoutePurpose
PATCH /v1/sandboxes/{id}/egressChange the policy on a running sandbox.
GET /v1/sandboxes/{id}/egress/denialsRefused hosts, newest first.
GET /v1/sandboxes/{id}/egress/activityAllowed and refused hosts, with completeness.
GET /v1/sandboxes/{id}/egress/usageThis month’s egress bytes and charge.
PUT /v1/teams/{id}/egressTeam default policy (team owners and admins).

Egress pricing is published at /v1/pricing/egress, and box.egress.usage() returns this sandbox’s bytes and charge for the month.