Skip to content
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,70 @@ const annotated = SandboxManager.annotateStderrWithSandboxFailures(
)
```

**Asking about a host no rule decides (the ask callback).** The second argument to `SandboxManager.initialize` is an optional `SandboxAskCallback`. It is asked about a destination that neither `network.deniedDomains`, nor `network.allowedDomains`, nor a per-command allow list (next section) decided, and it is never asked under `network.strictAllowlist`. Without a callback such a destination is denied. The callback resolves to one of:

- `true` - allow the connection. Only the value `true` allows.
- `false` - deny it. The violation line reports the generic reason `user denied`.
- `{ allow: false, reason: '...' }` - deny it, and report `reason` in the violation line in place of the generic text, so that whoever reads the `<sandbox_violations>` block (a model included) learns why and what to do instead.

Any other answer denies, whether it is truthy or not, and reports the generic reason. That includes an object that carries a `reason` without saying `allow: false`, such as `{ allow: true, reason: '...' }`: its reason is not reported.

The reason is sanitized before it is stored, the way the rest of a violation line is. Each run of control characters (line breaks and tabs included) or of invisible ones (zero-width characters, the joiner among them, and bidi controls) becomes one space; `<` and `>` are removed, so `re-run with <host> listed` is stored as `re-run with host listed`; the ends are trimmed. The result is cut to 500 characters, counted as UTF-16 code units the way `String.prototype.length` counts them. A reason with nothing left after that falls back to `user denied`. Write it as one line of plain text.

**Behaviour change in the first release after 0.0.79:** the filter used to allow on any truthy answer, so a callback that resolved to a truthy value other than `true` (`1`, a string, any object) allowed the connection. It now denies. For the same reason, `{ allow: false, reason }` returned to an older release would be read there as an allow, because an object is truthy. `SandboxManager.askCallbackDenyReason` is `true` on a release that understands the object: check it before returning one, and deny with a plain `false` where it is absent.

```typescript
const deny = (reason: string) =>
SandboxManager.askCallbackDenyReason
? { allow: false as const, reason }
: false

await SandboxManager.initialize(config, async ({ host, port }) => {
if (await userApproves(host, port)) return true
return deny(`${host} was not approved for this session`)
})
```

**Per-command network allow lists (`registerCommandNetworkLists`).** One invocation can carry an allow list of its own, in addition to the configured one, for as long as it runs:

```typescript
import { randomBytes } from 'node:crypto'

// 128 bits of randomness, 22 characters. See below for why nothing less will do.
const commandId = randomBytes(16).toString('base64url')
const wrapped = await SandboxManager.wrapWithSandbox(
command,
undefined,
undefined,
undefined,
{ commandId },
)

SandboxManager.registerCommandNetworkLists(commandId, {
allowedDomains: ['registry.example.org', '*.cdn.example.org:443'],
})
const child = spawn(wrapped, { shell: true })
const unregister = () => SandboxManager.unregisterCommandNetworkLists(commandId)
child.once('exit', unregister)
child.once('error', unregister) // the child never started
```

Entries use the grammar and the matcher of `network.allowedDomains` and get the same validation. `registerCommandNetworkLists` throws on an invalid entry, and on a `commandId` shorter than 22 characters (an unpaired surrogate does not count). Registering an id again replaces its list; `unregisterCommandNetworkLists` is a no-op for an id that has none; `reset()` removes every registration.

The order of evaluation for a connection is:

1. a `network.deniedDomains` match denies;
2. a `network.allowedDomains` match allows;
3. `network.strictAllowlist` denies;
4. a match in the list registered for the id the connection presents allows;
5. the ask callback decides if there is one, and otherwise the connection is denied.

So a per-command entry never overrides a configured `deniedDomains` entry, and is ignored entirely under `strictAllowlist`. It never enters `network.*` either: `getConfig()` and `getNetworkRestrictionConfig()` do not show it, and the default `injectHosts` scope of a masked credential, which is `network.allowedDomains`, does not grow. A host allowed this way still goes through the resolved-address check when it is dialed, and an IP literal in a per-command list adds no exemption there.

What the id has to be, and what this feature is not: the proxy learns which invocation a connection belongs to from the proxy username, and the username is presented by the client inside the sandbox. The id is therefore the **only** thing binding a connection to an allow list. It **must** be unguessable: at least 128 bits of randomness, never a counter, a timestamp or anything derived from the command. A sandboxed process that presents another live invocation's id gets that invocation's allows, so this is attribution, not a boundary between concurrent commands of one session. Register just before spawning the wrapped command, and unregister when the child exits or never started: a registration that outlives its command widens the window in which its id is worth presenting. As with every attribution key, only the first 100 characters of an id take part. Keep ids ASCII: a list under a non-ASCII id whose encoded form does not fit in the proxy username (255 bytes) registers and never applies.

No list applies while `network.httpProxyPort` names an external proxy: the wrap gives that proxy no username, so no connection carries an id. `registerCommandNetworkLists` still succeeds (and says so in one line under `SRT_DEBUG`), and every connection is decided as if no list existed.

#### Available exports

```typescript
Expand Down
80 changes: 80 additions & 0 deletions src/sandbox/domain-pattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
* (sandbox-manager) and config-time validation (sandbox-config).
* Lives in its own module so the schema can import it without pulling
* in sandbox-manager (which imports the schema — circular).
*
* The validity check for a list entry lives here too, so the manager can
* apply it to lists registered at run time without loading the schema module.
*/

import { isIP } from 'node:net'
Expand Down Expand Up @@ -73,6 +76,83 @@ function parsePortSuffix(suffix: string): number | undefined {
return port > 65535 ? undefined : port
}

/**
* Host-only pattern check (e.g., "example.com", "*.npmjs.org"). Rejects
* protocols, paths, ports, and overly broad wildcards.
*/
export function isValidDomainPattern(val: string): boolean {
// A bare IPv6 literal as produced by splitDomainPatternPort for a
// bracketed entry (`[::1]`, `[2001:db8::1]:443` -> `::1`, `2001:db8::1`).
// Whether the *raw* entry was bracketed is enforced separately
// (hasValidIpv6Bracketing) before the split.
if (isIP(val) === 6) return true

// Reject protocols, paths, ports, etc.
if (val.includes('://') || val.includes('/') || val.includes(':')) {
return false
}

// Allow localhost
if (val === 'localhost') return true

// Allow wildcard domains like *.example.com
if (val.startsWith('*.')) {
const domain = val.slice(2)
// After the *. there must be a valid domain with at least one more dot
// e.g., *.example.com is valid, *.com is not (too broad)
if (
!domain.includes('.') ||
domain.startsWith('.') ||
domain.endsWith('.')
) {
return false
}
// Count dots - must have at least 2 parts after the wildcard (e.g., example.com)
const parts = domain.split('.')
return parts.length >= 2 && parts.every(p => p.length > 0)
}

// Reject any other use of wildcards (e.g., *, *., etc.)
if (val.includes('*')) {
return false
}

// Regular domains must have at least one dot and only valid characters
return val.includes('.') && !val.startsWith('.') && !val.endsWith('.')
}

export const DOMAIN_PATTERN_MESSAGE =
'Invalid domain pattern. Must be a valid domain (e.g., "example.com"), a wildcard (e.g., "*.example.com"), or a bracketed IPv6 literal (e.g., "[::1]", "[2001:db8::1]:443"). Overly broad patterns like "*.com" or "*" are not allowed for security reasons.'

/**
* Raw-entry rule applied before the port split: an entry with two or more
* colons is an IPv6 literal and must use RFC 3986 brackets (`[::1]`,
* `[::1]:443`). Unbracketed it is ambiguous (`2001:db8::1:443` is itself a
* valid 8-hextet address), so reject it and make the user say which they
* mean, rather than accept an entry that can silently match the wrong thing.
*/
export function hasValidIpv6Bracketing(val: string): boolean {
const first = val.indexOf(':')
const multiColon = first !== -1 && val.indexOf(':', first + 1) !== -1
return !multiColon || val.startsWith('[')
}

/**
* The check an allow-list entry gets: a domain pattern with an optional
* `:port` suffix. Shared by the `network.allowedDomains` schema and the lists
* registered at run time, so neither accepts what the other refuses.
*/
export function isValidAllowedDomainEntry(val: string): boolean {
return (
hasValidIpv6Bracketing(val) &&
isValidDomainPattern(splitDomainPatternPort(val).hostPattern)
)
}

export const ALLOWED_DOMAIN_ENTRY_MESSAGE =
DOMAIN_PATTERN_MESSAGE +
' An optional ":port" suffix (1-65535) restricts the entry to that port.'

/**
* The IP-literal entries of an allow/deny list as `{ range, port? }` rules
* for the resolved-address check: an address the list names explicitly is
Expand Down
81 changes: 8 additions & 73 deletions src/sandbox/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
* This is the main configuration interface that consumers pass to SandboxManager.initialize()
*/

import { isIP } from 'node:net'
import type { FilterRequestCallback } from './request-filter.js'

import { isAbsolute, posix as posixPath, win32 as win32Path } from 'node:path'
Expand All @@ -13,62 +12,19 @@ import { isAbsolute, posix as posixPath, win32 as win32Path } from 'node:path'
// schemas the v4 API they are not written against.
import { z } from 'zod/v3'
import {
ALLOWED_DOMAIN_ENTRY_MESSAGE,
DOMAIN_PATTERN_MESSAGE,
hasValidIpv6Bracketing,
isInjectHostCoveredByAllowedDomains,
isValidAllowedDomainEntry,
isValidDomainPattern,
splitDomainPatternPort,
stripDomainPatternPort,
} from './domain-pattern.js'
import { parseAddressRange } from './address.js'
import { containsGlobCharsForPlatform } from './sandbox-utils.js'
import { getPlatform } from '../utils/platform.js'

/**
* Host-only pattern check (e.g., "example.com", "*.npmjs.org"). Rejects
* protocols, paths, ports, and overly broad wildcards.
*/
function isValidDomainPattern(val: string): boolean {
// A bare IPv6 literal as produced by splitDomainPatternPort for a
// bracketed entry (`[::1]`, `[2001:db8::1]:443` → `::1`, `2001:db8::1`).
// Whether the *raw* entry was bracketed is enforced separately
// (hasValidIpv6Bracketing) before the split.
if (isIP(val) === 6) return true

// Reject protocols, paths, ports, etc.
if (val.includes('://') || val.includes('/') || val.includes(':')) {
return false
}

// Allow localhost
if (val === 'localhost') return true

// Allow wildcard domains like *.example.com
if (val.startsWith('*.')) {
const domain = val.slice(2)
// After the *. there must be a valid domain with at least one more dot
// e.g., *.example.com is valid, *.com is not (too broad)
if (
!domain.includes('.') ||
domain.startsWith('.') ||
domain.endsWith('.')
) {
return false
}
// Count dots - must have at least 2 parts after the wildcard (e.g., example.com)
const parts = domain.split('.')
return parts.length >= 2 && parts.every(p => p.length > 0)
}

// Reject any other use of wildcards (e.g., *, *., etc.)
if (val.includes('*')) {
return false
}

// Regular domains must have at least one dot and only valid characters
return val.includes('.') && !val.startsWith('.') && !val.endsWith('.')
}

const DOMAIN_PATTERN_MESSAGE =
'Invalid domain pattern. Must be a valid domain (e.g., "example.com"), a wildcard (e.g., "*.example.com"), or a bracketed IPv6 literal (e.g., "[::1]", "[2001:db8::1]:443"). Overly broad patterns like "*.com" or "*" are not allowed for security reasons.'

/**
* Schema for domain patterns (e.g., "example.com", "*.npmjs.org")
* Validates that domain patterns are safe and don't include overly broad wildcards
Expand All @@ -81,32 +37,11 @@ const domainPatternSchema = z
* Domain pattern with an optional `:port` suffix (e.g., "example.com:443",
* "*.npmjs.org:8443"). Used for allowedDomains / deniedDomains, where the
* proxy knows the destination port; an entry without a port matches any port.
* The check and its message live in domain-pattern.ts, which says why.
*/
/**
* Raw-entry rule applied before the port split: an entry with two or more
* colons is an IPv6 literal and must use RFC 3986 brackets (`[::1]`,
* `[::1]:443`). Unbracketed it is ambiguous — `2001:db8::1:443` is itself a
* valid 8-hextet address — so reject it and make the user say which they
* mean, rather than accept an entry that can silently match the wrong thing.
*/
function hasValidIpv6Bracketing(val: string): boolean {
const first = val.indexOf(':')
const multiColon = first !== -1 && val.indexOf(':', first + 1) !== -1
return !multiColon || val.startsWith('[')
}

const domainPortPatternSchema = z
.string()
.refine(
val =>
hasValidIpv6Bracketing(val) &&
isValidDomainPattern(splitDomainPatternPort(val).hostPattern),
{
message:
DOMAIN_PATTERN_MESSAGE +
' An optional ":port" suffix (1-65535) restricts the entry to that port.',
},
)
.refine(isValidAllowedDomainEntry, { message: ALLOWED_DOMAIN_ENTRY_MESSAGE })

/**
* deniedDomains entry: a domainPortPattern, or a bare "*" / "*:port"
Expand Down Expand Up @@ -764,7 +699,7 @@ export const NetworkConfigSchema = z.object({
.boolean()
.optional()
.describe(
'If true, hosts not in allowedDomains are denied without consulting the ask callback. Set this when allowedDomains is policy enforcement, not a prompt-suppression hint.',
'If true, hosts not in allowedDomains are denied without consulting the ask callback, and a per-command allow list (registerCommandNetworkLists) is ignored. Set this when allowedDomains is policy enforcement, not a prompt-suppression hint.',
),
deniedResolvedAddresses: z
.array(addressRangeSchema)
Expand Down
Loading
Loading