Guides / JavaScript & TypeScript / Network and secrets
Network and secrets
Every sandbox has one network rule, chosen when it is created and enforced on your computer, outside the virtual machine. This page shows each rule from Node, what a refusal looks like from inside, how to read the counters, and the broker that lets a sandbox use an API key it never holds.
The rules themselves, and where they are enforced, are explained in Network rules. This page is about driving them from code.
The rules
egress | What leaves the sandbox |
|---|---|
'deny' | Nothing: no connection, no datagram, no DNS lookup. |
'allow:a.com,*.b.com,10.0.0.0/8'or { allow: [...] }or egressAllow: [...] | Only those names, wildcards, IPv4 addresses and IPv4 networks. Everything else is refused. DNS is answered on your computer, so a name resolves only if it is on the list. |
'any' | Everything, unlogged. This is what you get when you set nothing and no org policy sets default_egress. |
'fakenet' | Nothing leaves; a simulated internet on your computer answers and records every request. Meant for malware analysis (kosa8 lab). Not demonstrated on this page. |
An allow-list, and no network at all
import { Kosa8Client } from '@kosa8/sdk';
const kosa8 = new Kosa8Client();
const fetchBoth = ['sh', '-c',
'for u in https://example.com https://github.com; do ' +
' if wget -q -T 5 -O /dev/null "$u"; then echo "$u reached"; else echo "$u refused"; fi; ' +
'done'];
// Only example.com. Everything else is refused on the host.
await kosa8.createSandbox({
name: 'allow1',
egressAllow: ['example.com'],
capture: { path: '/tmp/k8d/allow1.pcapng' },
});
await kosa8.pullIntoSandbox('allow1', 'alpine:latest');
console.log((await kosa8.run('allow1', 'alpine:latest', fetchBoth)).stdout);
console.log(await kosa8.sandboxEgress('allow1'));
// No network at all.
await kosa8.createSandbox({ name: 'deny1', egress: 'deny' });
await kosa8.pullIntoSandbox('deny1', 'alpine:latest');
console.log((await kosa8.run('deny1', 'alpine:latest', fetchBoth)).stdout);
console.log(await kosa8.sandboxEgress('deny1'));
$ node net1.mjs
https://example.com reached
https://github.com refused
{
policy: 'allow:example.com',
stats: {
passed: 13,
denied: 8,
dropped: 7,
dns_answered: 6,
dns_refused: 8
},
capture: '/tmp/k8d/allow1.pcapng'
}
https://example.com refused
https://github.com refused
{
policy: 'deny',
stats: { passed: 2, denied: 0, dropped: 8, dns_answered: 0, dns_refused: 0 }
}
sandboxEgress returns the rule the host enforces and what it has decided so far:
frames passed, denied, and dropped as link noise (IPv6 and multicast chatter every Linux machine
sends at boot), and DNS questions answered and refused. With a *.domain entry it also
lists the names each wildcard has matched.
What a refusal looks like from inside
import { Kosa8Client } from '@kosa8/sdk';
const kosa8 = new Kosa8Client();
// What a refused request looks like from inside.
const r = await kosa8.run('allow1', 'alpine:latest', ['wget', '-T', '5', '-O', '/dev/null', 'https://github.com']);
console.log('exit', r.exitCode, '|', r.stderr.trim());
exit 1 | wget: bad address 'github.com'
A name that is not on the list does not resolve, so the program fails at once with an ordinary
error and a non-zero exit code. Nothing is thrown in your Node code: run resolves
with the exit code. Each refusal is also written to the audit log
(kosa8 audit ls shows sandbox.egress.deny entries).
Rules that are refused
A rule kosa8 does not fully understand is refused, never guessed at. The daemon answers with HTTP 400, and two mistakes are caught in the client before anything is sent:
// Policies the daemon refuses, and the two mistakes the client refuses before sending anything.
for (const egress of ['allow:*.com', 'allow:*', 'allow:2001:db8::1', 'open']) {
try {
await kosa8.createSandbox({ name: 'bad', egress });
} catch (err) {
console.log(`${egress} -> ${err.constructor.name} ${err.status ?? ''}: ${err.message}`);
}
}
for (const opts of [{ egress: 'any', egressAllow: ['example.com'] }, { egress: { allow: [] } }]) {
try {
await kosa8.createSandbox({ name: 'bad', ...opts });
} catch (err) {
console.log(`${JSON.stringify(opts)} -> ${err.constructor.name}: ${err.message}`);
}
}
allow:*.com -> Kosa8BadRequestError 400: egress allow "*.com": com is a public suffix, under which anyone can register a name, so the wildcard would admit strangers' hosts; allow a domain below it, as in *.example.com
allow:* -> Kosa8BadRequestError 400: egress allow "*": a bare * would allow every name; use --egress any, or list *.domain entries
allow:2001:db8::1 -> Kosa8BadRequestError 400: egress allow "2001:db8::1": only IPv4 is routed from a sandbox; list an IPv4 address or a hostname
open -> Kosa8BadRequestError 400: egress policy "open": want any, deny, fakenet, or allow:HOST[,HOST...]
{"egress":"any","egressAllow":["example.com"]} -> Kosa8Error: egressAllow already limits the network to the hosts it lists; do not also set egress
{"egress":{"allow":[]}} -> Kosa8Error: an egress allow-list needs at least one host; use egress: "deny" for no network
The rule cannot change while the sandbox runs
There is no call to change a running sandbox's egress, by design: a rule an agent could widen from inside the session would not be a rule. To change it, create a new sandbox. To carry state across, use a snapshot, which keeps the egress it was taken with, or a host folder mount.
Keep API keys out: the broker
An agent needs to call a model API, and anything inside the sandbox can read whatever key you put there. The broker solves this. It is a proxy on your computer, one per sandbox, and the only way out of it. You give the daemon the real key once, over your socket. The sandbox gets a placeholder. The broker swaps the placeholder for the real key on the way out, and only for the upstream, methods and paths you named.
import { Kosa8Client } from '@kosa8/sdk';
const kosa8 = new Kosa8Client();
// The real secret goes to the daemon over your socket and stays in its memory.
const created = await kosa8.createSandbox({
name: 'brokered',
egress: 'deny', // with a broker, the only way out is the broker
broker: {
allow: [{ host: 'example.com' }], // reachable through the broker's proxy, no credential
routes: [{
name: 'httpbin', // reachable at <broker url>/httpbin
upstream: 'https://httpbin.org',
header: 'x-api-key', // the header the broker sets on the way out
methods: ['GET'],
paths: ['/headers'],
secret: 'sk-live-THE-REAL-KEY',
}],
},
});
console.log('sandbox.broker:', created.sandbox.broker);
console.log('broker:', created.broker);
const { url, placeholders } = created.broker;
await kosa8.pullIntoSandbox('brokered', 'curlimages/curl:latest');
const r = await kosa8.run('brokered', 'curlimages/curl:latest', ['sh', '-c', [
'echo "the sandbox holds: $API_KEY"',
'curl -s -H "x-api-key: $API_KEY" "$BASE/headers" | grep -i x-api-key',
'curl -s -o /dev/null -w "example.com via the proxy: HTTP %{http_code}\\n" https://example.com',
'curl -s -o /dev/null https://github.com || echo "github.com: refused (curl exit $?)"',
].join('; ')], {
env: {
API_KEY: placeholders.httpbin,
BASE: `${url}/httpbin`,
HTTPS_PROXY: url,
NO_PROXY: new URL(url).hostname,
},
});
console.log(r.stdout);
$ node broker.mjs
sandbox.broker: {
url: 'http://192.168.127.254:59393',
hosts: [ 'example.com' ],
routes: [ 'httpbin -> httpbin.org' ]
}
broker: {
placeholders: { httpbin: 'kosa8-placeholder-85d9e0acdc031a0fc7003a39af4b069d' },
url: 'http://192.168.127.254:59393'
}
the sandbox holds: kosa8-placeholder-85d9e0acdc031a0fc7003a39af4b069d
"X-Api-Key": "sk-live-THE-REAL-KEY"
example.com via the proxy: HTTP 200
github.com: refused (curl exit 7)
Read the output from the top. The sandbox's environment holds only the placeholder. httpbin.org,
which echoes the headers it received, got the real key, because the broker added it on the way
out. example.com is on the broker's allow list and passes through its
proxy with no credential. Everything else is refused.
After this run, a search of the whole state directory for the secret found nothing: the daemon keeps it in memory for the life of the sandbox and writes it nowhere, not to the sandbox record, a snapshot, or the audit log.
$ grep -rl "THE-REAL-KEY" /tmp/k8d/.kosa8/ | wc -l
0
The broker spec
| Field | Meaning |
|---|---|
allow[] | Hosts reachable through the broker's proxy with no credential: { host, ports?, methods? }. host is an exact name, a *.domain wildcard or an IPv4 address. Ports default to 443 and 80. |
routes[] | Credentialed upstreams: { name, upstream, header, secret, methods?, paths?, scheme? }. The sandbox calls <broker url>/<name>/…, and the broker forwards to upstream with header set from secret. |
result broker.url | The broker as the sandbox sees it. Use it as HTTPS_PROXY, and as the base URL of each route. |
result broker.placeholders | Route name to the stand-in the sandbox should present. Returned by createSandbox and nowhere else, so keep it. |
For a model API, point the client inside the sandbox at the route and give it the
placeholder as its key: for Anthropic, a route with upstream: 'https://api.anthropic.com'
and header: 'x-api-key', then ANTHROPIC_BASE_URL=<broker url>/anthropic
and ANTHROPIC_API_KEY=<placeholder>, as in the worked example on
For developers. That route was not run for this page; the
httpbin route above was. Keep API keys out covers the broker's
design.
- Busybox
wget(inalpine) works for route calls but not as an HTTPS client through the proxy: the broker answered its proxied requests with400 Bad Request.curlworks both ways, so the example usescurlimages/curl. Fixed in kosa8 0.9.36: busybox sendsGET https://…to the proxy instead ofCONNECT, and the broker now forwards that, admitting it by host, port and method and verifying the certificate itself. - The broker refuses
egress: 'any': beside an open network it would be optional. Leaveegressunset or'deny'. - The secret lives only in the daemon's memory. A brokered sandbox that is stopped and started, restored or forked comes back with its network closed and no broker. Create a fresh one each time.
Running code: run, exec, streaming output, stdin and time limits.