Sandbox hardening: TMPDIR write scope and seccomp arg comparison (#182)

* Harden macOS sandbox to only use the configured TMPDIR for writes

* Use 32-bit masked comparison for socket() domain argument in seccomp filter
This commit is contained in:
David Dworken
2026-03-23 17:41:10 -07:00
committed by GitHub
parent 20f5176a94
commit 62e61c0e74
8 changed files with 18 additions and 65 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@anthropic-ai/sandbox-runtime",
"version": "0.0.42",
"version": "0.0.43",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@anthropic-ai/sandbox-runtime",
"version": "0.0.42",
"version": "0.0.43",
"license": "Apache-2.0",
"dependencies": {
"@pondwader/socks5-server": "^1.0.10",

View File

@@ -1,6 +1,6 @@
{
"name": "@anthropic-ai/sandbox-runtime",
"version": "0.0.42",
"version": "0.0.43",
"description": "Anthropic Sandbox Runtime (ASRT) - A general-purpose tool for wrapping security boundaries around arbitrary processes",
"type": "module",
"main": "./dist/index.js",

View File

@@ -99,6 +99,11 @@ build_platform() {
echo 'Binary size:'
ls -lh /output/seccomp-unix-block
echo ''
echo 'Generating BPF filter...'
/output/seccomp-unix-block /output/unix-block.bpf
ls -lh /output/unix-block.bpf
echo ''
echo 'Building apply-seccomp (no libseccomp dependency)...'
gcc -o /output/apply-seccomp /src/apply-seccomp.c \
@@ -124,23 +129,7 @@ build_platform() {
return 1
}
# Verify binary exists
if [ ! -f "$output_dir/seccomp-unix-block" ]; then
echo "✗ Error: Binary not found in $output_dir"
return 1
fi
# Generate BPF filter using the seccomp-unix-block binary
echo "Generating BPF filter..."
local bpf_file="$output_dir/unix-block.bpf"
# Run the generator to create the BPF file
if ! "$output_dir/seccomp-unix-block" "$bpf_file" 2>&1; then
echo "✗ Error: Failed to generate BPF filter"
return 1
fi
# Verify BPF file was created
# Verify BPF file was created inside the container
if [ ! -f "$bpf_file" ]; then
echo "✗ Error: BPF file not created"
return 1
@@ -149,7 +138,6 @@ build_platform() {
echo "✓ BPF filter generated: $(ls -lh "$bpf_file" | awk '{print $5}')"
# Remove the generator binary (we only need the BPF file)
echo "Removing generator binary to save space..."
rm -f "$output_dir/seccomp-unix-block"
# Verify final state

View File

@@ -265,11 +265,8 @@ function generateReadRules(
// directory (e.g. /Users, /Users/chris) even if only a subdirectory like
// ~/.local is in allowWithinDeny. This only allows metadata reads on
// directories — not listing contents (readdir) or reading files.
if ((config.denyOnly).length > 0) {
rules.push(
`(allow file-read-metadata`,
` (vnode-type DIRECTORY))`,
)
if (config.denyOnly.length > 0) {
rules.push(`(allow file-read-metadata`, ` (vnode-type DIRECTORY))`)
}
// Block file movement to prevent bypass via mv/rename
@@ -292,17 +289,6 @@ function generateWriteRules(
const rules: string[] = []
// Automatically allow TMPDIR parent on macOS when write restrictions are enabled
const tmpdirParents = getTmpdirParentIfMacOSPattern()
for (const tmpdirParent of tmpdirParents) {
const normalizedPath = normalizePathForSandbox(tmpdirParent)
rules.push(
`(allow file-write*`,
` (subpath ${escapePath(normalizedPath)})`,
` (with message "${logTag}"))`,
)
}
// Generate allow rules
for (const pathPattern of config.allowOnly || []) {
const normalizedPath = normalizePathForSandbox(pathPattern)
@@ -651,31 +637,6 @@ function escapePath(pathStr: string): string {
return JSON.stringify(pathStr)
}
/**
* Get TMPDIR parent directory if it matches macOS pattern /var/folders/XX/YYY/T/
* Returns both /var/ and /private/var/ versions since /var is a symlink
*/
function getTmpdirParentIfMacOSPattern(): string[] {
const tmpdir = process.env.TMPDIR
if (!tmpdir) return []
const match = tmpdir.match(
/^\/(private\/)?var\/folders\/[^/]{2}\/[^/]+\/T\/?$/,
)
if (!match) return []
const parent = tmpdir.replace(/\/T\/?$/, '')
// Return both /var/ and /private/var/ versions since /var is a symlink
if (parent.startsWith('/private/var/')) {
return [parent, parent.replace('/private', '')]
} else if (parent.startsWith('/var/')) {
return [parent, '/private' + parent]
}
return [parent]
}
/**
* Wrap command with macOS sandbox
*/

View File

@@ -46,8 +46,8 @@ function cleanupTmpClaude(): void {
describe('macOS Seatbelt Symlink Boundary Validation', () => {
// Use unique test directories per run
// IMPORTANT: Use /private/tmp (not os.tmpdir() which is /var/folders/...)
// because /var/folders is automatically allowed by the sandbox via TMPDIR parent rule
// Use /private/tmp (not os.tmpdir()) so test paths are outside any
// default-allowed write location
const TEST_ID = Date.now()
const TEST_BASE_DIR = `/private/tmp/symlink-boundary-test-${TEST_ID}`
const WORKSPACE_DIR = join(TEST_BASE_DIR, 'workspace')

View File

@@ -65,8 +65,12 @@ int main(int argc, char *argv[]) {
/* Add rule to block socket(AF_UNIX, ...) */
/* socket() syscall signature: int socket(int domain, int type, int protocol) */
/* arg0 = domain (AF_UNIX = 1) */
/* Use SCMP_CMP_MASKED_EQ with a 32-bit mask: the domain argument is a 32-bit
* int, so the kernel ignores the upper 32 bits of the register. A plain
* SCMP_CMP_EQ would compare all 64 bits and miss calls where the upper bits
* are set. */
rc = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(socket), 1,
SCMP_A0(SCMP_CMP_EQ, AF_UNIX));
SCMP_A0(SCMP_CMP_MASKED_EQ, 0xffffffff, AF_UNIX));
if (rc < 0) {
fprintf(stderr, "Error: Failed to add seccomp rule: %s\n", strerror(-rc));
seccomp_release(ctx);

Binary file not shown.

Binary file not shown.