From ebe27b1551e0711e43b825511a5fea6e282e6ed2 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Thu, 2 Apr 2026 12:13:09 -0700 Subject: [PATCH] Add seccomp argv0 mode for multicall-binary invocation When the caller has apply-seccomp compiled into its own executable (busybox-style multicall dispatch), there is no standalone binary on disk to resolve. seccompConfig.argv0 lets the caller supply the dispatch name and a verbatim applyPath (e.g. an inherited-fd path like /proc/self/fd/N); the wrapped invocation becomes ARGV0= -c and the on-disk getApplySeccompBinaryPath lookup is skipped. - sandbox-config: optional argv0 string on SeccompConfigSchema (parallel to ripgrep.argv0) - linux-sandbox-utils: resolveApplySeccompPrefix helper returns a shell-ready prefix for both modes; non-argv0 output is byte-identical to before. Dependency checks short-circuit the disk lookup when argv0 is set. Inline {applyPath?; argv0?} shapes replaced with the SeccompConfig type. - sandbox-manager: use SeccompConfig type - tests: argv0 dependency-check short-circuit, wrapped-command prefix format, shell-quoting of hostile inputs, argv0-without-applyPath error --- src/sandbox/linux-sandbox-utils.ts | 88 +++++++++++++-------- src/sandbox/sandbox-config.ts | 11 +++ src/sandbox/sandbox-manager.ts | 4 +- test/sandbox/linux-dependency-error.test.ts | 24 ++++++ test/sandbox/seccomp-filter.test.ts | 40 ++++++++++ 5 files changed, 134 insertions(+), 33 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 68af100..93de9dd 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -21,6 +21,7 @@ import type { FsWriteRestrictionConfig, } from './sandbox-schemas.js' import { getApplySeccompBinaryPath } from './generate-seccomp-filter.js' +import type { SeccompConfig } from './sandbox-config.js' export interface LinuxNetworkBridgeContext { httpSocketPath: string @@ -49,7 +50,7 @@ export interface LinuxSandboxParams { /** Allow writes to .git/config files (default: false) */ allowGitConfig?: boolean /** Custom seccomp binary paths */ - seccompConfig?: { applyPath?: string } + seccompConfig?: SeccompConfig /** Abort signal to cancel the ripgrep scan */ abortSignal?: AbortSignal } @@ -390,23 +391,26 @@ export type SandboxDependencyCheck = { /** * Get detailed status of Linux sandbox dependencies */ -export function getLinuxDependencyStatus(seccompConfig?: { - applyPath?: string -}): LinuxDependencyStatus { +export function getLinuxDependencyStatus( + seccompConfig?: SeccompConfig, +): LinuxDependencyStatus { + // argv0 mode: apply-seccomp is compiled into the caller's binary — skip + // the on-disk lookup and trust that applyPath resolves inside bwrap. return { hasBwrap: whichSync('bwrap') !== null, hasSocat: whichSync('socat') !== null, - hasSeccompApply: - getApplySeccompBinaryPath(seccompConfig?.applyPath) !== null, + hasSeccompApply: seccompConfig?.argv0 + ? true + : getApplySeccompBinaryPath(seccompConfig?.applyPath) !== null, } } /** * Check sandbox dependencies and return structured result */ -export function checkLinuxDependencies(seccompConfig?: { - applyPath?: string -}): SandboxDependencyCheck { +export function checkLinuxDependencies( + seccompConfig?: SeccompConfig, +): SandboxDependencyCheck { const errors: string[] = [] const warnings: string[] = [] @@ -414,7 +418,10 @@ export function checkLinuxDependencies(seccompConfig?: { errors.push('bubblewrap (bwrap) not installed') if (whichSync('socat') === null) errors.push('socat not installed') - if (getApplySeccompBinaryPath(seccompConfig?.applyPath) === null) { + if ( + !seccompConfig?.argv0 && + getApplySeccompBinaryPath(seccompConfig?.applyPath) === null + ) { warnings.push('seccomp not available - unix socket access not restricted') } @@ -575,6 +582,31 @@ export async function initializeLinuxNetworkBridge( } } +/** + * Resolve how to invoke apply-seccomp: either a standalone binary path, or a + * multicall-binary prefix that dispatches on the ARGV0 env var. + * + * Returns a shell-ready string ending in a trailing space — callers append + * shellquote.quote([shell, '-c', cmd]). Returns undefined when seccomp is + * unavailable (no argv0, no binary found). + * + * When argv0 is set, applyPath is used verbatim (no existence check); the + * caller is responsible for ensuring it resolves inside the bwrap namespace. + */ +function resolveApplySeccompPrefix( + applyPath: string | undefined, + argv0: string | undefined, +): string | undefined { + if (argv0) { + if (!applyPath) { + throw new Error('seccompConfig.argv0 requires seccompConfig.applyPath') + } + return `ARGV0=${shellquote.quote([argv0])} ${shellquote.quote([applyPath])} ` + } + const binary = getApplySeccompBinaryPath(applyPath) + return binary ? `${shellquote.quote([binary])} ` : undefined +} + /** * Build the command that runs inside the sandbox. * Sets up HTTP proxy on port 3128 and SOCKS proxy on port 1080 @@ -583,7 +615,7 @@ function buildSandboxCommand( httpSocketPath: string, socksSocketPath: string, userCommand: string, - applySeccompBinary: string | undefined, + applySeccompPrefix: string | undefined, shell?: string, ): string { // Default to bash for backward compatibility @@ -595,13 +627,9 @@ function buildSandboxCommand( ] // apply-seccomp runs after socat so socat can still create Unix sockets. - if (applySeccompBinary) { - const applySeccompCmd = shellquote.quote([ - applySeccompBinary, - shellPath, - '-c', - userCommand, - ]) + if (applySeccompPrefix) { + const applySeccompCmd = + applySeccompPrefix + shellquote.quote([shellPath, '-c', userCommand]) const innerScript = [...socatCommands, applySeccompCmd].join('\n') return `${shellPath} -c ${shellquote.quote([innerScript])}` } else { @@ -1036,17 +1064,19 @@ export async function wrapCommandWithSandboxLinux( activeSandboxCount++ const bwrapArgs: string[] = ['--new-session', '--die-with-parent'] - let applySeccompBinary: string | undefined + let applySeccompPrefix: string | undefined try { // ========== SECCOMP FILTER (Unix Socket Blocking) ========== // apply-seccomp wraps the workload and applies the baked-in BPF filter // that blocks socket(AF_UNIX, ...). Skipped when allowAllUnixSockets is true. if (!allowAllUnixSockets) { - applySeccompBinary = - getApplySeccompBinaryPath(seccompConfig?.applyPath) ?? undefined + applySeccompPrefix = resolveApplySeccompPrefix( + seccompConfig?.applyPath, + seccompConfig?.argv0, + ) - if (!applySeccompBinary) { + if (!applySeccompPrefix) { logForDebugging( '[Sandbox Linux] apply-seccomp binary not available - unix socket blocking disabled. ' + 'Install @anthropic-ai/sandbox-runtime globally for full protection.', @@ -1188,17 +1218,13 @@ export async function wrapCommandWithSandboxLinux( httpSocketPath, socksSocketPath, command, - applySeccompBinary, + applySeccompPrefix, shell, ) bwrapArgs.push(sandboxCommand) - } else if (applySeccompBinary) { - const applySeccompCmd = shellquote.quote([ - applySeccompBinary, - shell, - '-c', - command, - ]) + } else if (applySeccompPrefix) { + const applySeccompCmd = + applySeccompPrefix + shellquote.quote([shell, '-c', command]) bwrapArgs.push(applySeccompCmd) } else { bwrapArgs.push(command) @@ -1210,7 +1236,7 @@ export async function wrapCommandWithSandboxLinux( if (needsNetworkRestriction) restrictions.push('network') if (hasReadRestrictions || hasWriteRestrictions) restrictions.push('filesystem') - if (applySeccompBinary) restrictions.push('seccomp(unix-block)') + if (applySeccompPrefix) restrictions.push('seccomp(unix-block)') logForDebugging( `[Sandbox Linux] Wrapped command with bwrap (${restrictions.join(', ')} restrictions)`, diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 53e4a0f..8d5a086 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -209,6 +209,17 @@ export const RipgrepConfigSchema = z.object({ */ export const SeccompConfigSchema = z.object({ applyPath: z.string().optional().describe('Path to the apply-seccomp binary'), + argv0: z + .string() + .optional() + .describe( + 'Invoke apply-seccomp as a multicall binary that dispatches on the ' + + 'ARGV0 environment variable. When set, applyPath is used verbatim ' + + '(no existence check) and the invocation inside bwrap is prefixed ' + + 'with ARGV0=. The caller is responsible for ensuring ' + + 'applyPath resolves inside the bwrap namespace and that the target ' + + 'binary implements the apply-seccomp interface when ARGV0 matches.', + ), }) /** diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index e4df81b..3acc533 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -6,7 +6,7 @@ import { whichSync } from '../utils/which.js' import { cloneDeep } from 'lodash-es' import { getPlatform, getWslVersion } from '../utils/platform.js' import * as fs from 'fs' -import type { SandboxRuntimeConfig } from './sandbox-config.js' +import type { SandboxRuntimeConfig, SeccompConfig } from './sandbox-config.js' import type { SandboxAskCallback, FsReadRestrictionConfig, @@ -528,7 +528,7 @@ function getAllowGitConfig(): boolean { return config?.filesystem?.allowGitConfig ?? false } -function getSeccompConfig(): { applyPath?: string } | undefined { +function getSeccompConfig(): SeccompConfig | undefined { return config?.seccomp } diff --git a/test/sandbox/linux-dependency-error.test.ts b/test/sandbox/linux-dependency-error.test.ts index 66fa244..f56ac60 100644 --- a/test/sandbox/linux-dependency-error.test.ts +++ b/test/sandbox/linux-dependency-error.test.ts @@ -81,6 +81,18 @@ describe('checkLinuxDependencies', () => { expect(applySpy).toHaveBeenCalledWith('/custom/apply') }) + + test('argv0 mode: no seccomp warning even when binary lookup would fail', () => { + applySpy.mockReturnValue(null) + + const result = checkLinuxDependencies({ + argv0: 'apply-seccomp', + applyPath: '/proc/self/fd/3', + }) + + expect(result.warnings).toEqual([]) + expect(applySpy).not.toHaveBeenCalled() + }) }) describe('getLinuxDependencyStatus', () => { @@ -123,4 +135,16 @@ describe('getLinuxDependencyStatus', () => { expect(status.hasBwrap).toBe(true) expect(status.hasSocat).toBe(true) }) + + test('argv0 mode: hasSeccompApply is true without touching disk', () => { + applySpy.mockReturnValue(null) + + const status = getLinuxDependencyStatus({ + argv0: 'apply-seccomp', + applyPath: '/does/not/exist', + }) + + expect(status.hasSeccompApply).toBe(true) + expect(applySpy).not.toHaveBeenCalled() + }) }) diff --git a/test/sandbox/seccomp-filter.test.ts b/test/sandbox/seccomp-filter.test.ts index e788bc2..b838086 100644 --- a/test/sandbox/seccomp-filter.test.ts +++ b/test/sandbox/seccomp-filter.test.ts @@ -82,4 +82,44 @@ describe.if(isLinux)('Sandbox Integration', () => { expect(wrappedCommand).toContain(real) }) + + it('argv0 mode: builds ARGV0 prefix and uses applyPath verbatim', async () => { + if (checkLinuxDependencies().errors.length > 0) return + + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: 'echo test', + needsNetworkRestriction: false, + writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + seccompConfig: { argv0: 'apply-seccomp', applyPath: '/proc/self/fd/3' }, + }) + + expect(wrappedCommand).toContain('ARGV0=apply-seccomp /proc/self/fd/3 ') + expect(wrappedCommand).not.toContain('vendor/seccomp') + }) + + it('argv0 mode: shell-quotes argv0 and applyPath', async () => { + if (checkLinuxDependencies().errors.length > 0) return + + const wrappedCommand = await wrapCommandWithSandboxLinux({ + command: 'echo test', + needsNetworkRestriction: false, + writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + seccompConfig: { argv0: 'x; rm -rf /', applyPath: '/path with space' }, + }) + + expect(wrappedCommand).toContain("ARGV0='x; rm -rf /' '/path with space' ") + }) + + it('argv0 mode: rejects argv0 without applyPath', () => { + if (checkLinuxDependencies().errors.length > 0) return + + expect( + wrapCommandWithSandboxLinux({ + command: 'echo test', + needsNetworkRestriction: false, + writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] }, + seccompConfig: { argv0: 'apply-seccomp' }, + }), + ).rejects.toThrow('seccompConfig.argv0 requires seccompConfig.applyPath') + }) })