From 223de24298cfff870cc114e58c13dd7d4fb3448d Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Tue, 26 May 2026 17:53:43 -0700 Subject: [PATCH 01/18] feat(network): add allowAllDomains flag to bypass allowlist When `network.allowAllDomains: true`, filterNetworkRequest short-circuits to allow after the deniedDomains check runs, so explicit denies still take effect. Intended for environments where filesystem restrictions are the primary boundary and enumerating every reachable domain is impractical (e.g. opening web docs lookups in an otherwise locked-down sandbox). The flag is optional and additive: existing configs are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sandbox/sandbox-config.ts | 10 ++++++++++ src/sandbox/sandbox-manager.ts | 7 +++++++ test/config-validation.test.ts | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index bea85b285..ccbd13248 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -295,6 +295,16 @@ export const NetworkConfigSchema = z.object({ .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.', ), + allowAllDomains: z + .boolean() + .optional() + .describe( + 'If true, permit network egress to any domain. deniedDomains is still ' + + 'enforced (checked before the allow-all decision), so explicit denies ' + + 'still take effect. Intended for environments where filesystem ' + + 'restrictions are relied on as the primary boundary and the ' + + 'allowlist would otherwise need to enumerate the entire public web.', + ), allowUnixSockets: z .array(z.string()) .optional() diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 7c2d99db9..dbcbcbd39 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -191,6 +191,13 @@ async function filterNetworkRequest( } } + // allowAllDomains short-circuits the allowlist after denies are checked, + // so explicit deniedDomains entries still take effect. + if (config.network.allowAllDomains) { + logForDebugging(`Allowed by allowAllDomains: ${host}:${port}`) + return true + } + // Check allowed domains for (const allowedDomain of config.network.allowedDomains) { if (matchesDomainPattern(canonicalHost, allowedDomain)) { diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index 30ee7bbe3..978a63da2 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -87,6 +87,24 @@ describe('Config Validation', () => { expect(result.success).toBe(false) }) + test('should validate config with allowAllDomains flag', () => { + const config = { + network: { + allowedDomains: [], + deniedDomains: ['evil.com'], + allowAllDomains: true, + }, + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [], + }, + } + + const result = SandboxRuntimeConfigSchema.safeParse(config) + expect(result.success).toBe(true) + }) + test('should validate config with optional fields', () => { const config = { network: { From 4c57da41f9ed974314dd06d20d53d2b190d69c32 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Wed, 27 May 2026 11:34:40 -0700 Subject: [PATCH 02/18] feat(filesystem): add denyReadAlways for denies that beat allowRead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read restrictions today have only two layers — denyRead and allowRead — with allowRead unconditionally winning. That makes it impossible to broadly allow a directory (e.g. ~/src for cross-project work) while still keeping credential-style files (.env, *.pem, ~/.aws/credentials) denied within it. The write side already has the symmetric capability via denyWrite, so reads were the odd one out. New optional `denyReadAlways: string[]` in FilesystemConfigSchema. On macOS it emits Seatbelt (deny file-read*) rules AFTER the allowRead loop so they take precedence (last-match-wins). On Linux it emits bubblewrap binds (--tmpfs for dirs, --ro-bind /dev/null for files) after the allowRead re-bind loop for the same effect. denyReadAlways paths are also passed to generateMoveBlockingRules so mv/rename cannot bypass the deny by relocating the file. Glob handling reuses the existing helpers — patterns work the same as denyRead. Patterns with a leading "/" match globally (e.g. "/**/.env*"); without it they are CWD-relative, matching existing denyRead behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sandbox/linux-sandbox-utils.ts | 29 +++++++++++++++++++++++ src/sandbox/macos-sandbox-utils.ts | 37 +++++++++++++++++++++++++++--- src/sandbox/sandbox-config.ts | 9 ++++++++ src/sandbox/sandbox-manager.ts | 34 +++++++++++++++++++++++++++ src/sandbox/sandbox-schemas.ts | 11 +++++++-- test/config-validation.test.ts | 19 +++++++++++++++ 6 files changed, 134 insertions(+), 5 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 91b5e4921..a6cc63eb3 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1115,6 +1115,35 @@ async function generateFilesystemArgs( maskedFiles.set(realPath, fakePath) } + // denyReadAlways: final-deny pass that takes precedence over allowRead. + // Emitted AFTER the allowRead re-bind loop above so these binds win. + // Bubblewrap applies binds in argument order — later binds shadow earlier + // ones for the same destination path. + for (const denyPath of readConfig?.denyAlways || []) { + const normalizedPath = normalizePathForSandbox(denyPath) + if (!fs.existsSync(normalizedPath)) { + logForDebugging( + `[Sandbox Linux] Skipping non-existent denyReadAlways path: ${normalizedPath}`, + ) + continue + } + const stat = fs.statSync(normalizedPath) + if (stat.isDirectory()) { + args.push('--tmpfs', normalizedPath) + logForDebugging( + `[Sandbox Linux] denyReadAlways masked directory with tmpfs: ${normalizedPath}`, + ) + } else { + const denyDest = resolveSymlinkDenyDest(normalizedPath) + args.push('--ro-bind', '/dev/null', denyDest) + maskedFiles.set(denyDest, '/dev/null') + maskedFiles.set(normalizedPath, '/dev/null') + logForDebugging( + `[Sandbox Linux] denyReadAlways masked file: ${normalizedPath}`, + ) + } + } + // Emitting denyWrite last means these ro-binds layer on top of any write // paths the denyRead loop just re-bound. Before this ordering, tmpfs over // an ancestor of cwd would wipe the .git/hooks protection. But skip any diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index a2935116a..3f0006a0f 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -318,18 +318,49 @@ function generateReadRules( } } + // denyAlways: final-deny pass that takes precedence over allowWithinDeny. + // Seatbelt is last-match-wins, so these deny rules emitted after the + // allowWithinDeny loop will fire when both match. Intended for + // credential-style globs (e.g. "/**/.env*") inside a broadly-allowed + // directory. + for (const pathPattern of config.denyAlways || []) { + const normalizedPath = normalizePathForSandbox(pathPattern) + + if (containsGlobChars(normalizedPath)) { + const regexPattern = globToRegex(normalizedPath) + rules.push( + `(deny file-read*`, + ` (regex ${escapePath(regexPattern)})`, + ` (with message "${logTag}"))`, + ) + } else { + rules.push( + `(deny file-read*`, + ` (subpath ${escapePath(normalizedPath)})`, + ` (with message "${logTag}"))`, + ) + } + } + // Allow stat/lstat on all directories so that realpath() can traverse // path components within denied regions. Without this, C realpath() fails // when resolving symlinks because it needs to lstat every intermediate // 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) { + if (config.denyOnly.length > 0 || (config.denyAlways || []).length > 0) { rules.push(`(allow file-read-metadata`, ` (vnode-type DIRECTORY))`) } - // Block file movement to prevent bypass via mv/rename - rules.push(...generateMoveBlockingRules(config.denyOnly || [], logTag)) + // Block file movement to prevent bypass via mv/rename. + // Include denyAlways paths so a compromised tool can't rename a credential + // file out of a denyAlways match. + rules.push( + ...generateMoveBlockingRules( + [...(config.denyOnly || []), ...(config.denyAlways || [])], + logTag, + ), + ) // Re-allow file-write-unlink / file-write-create for paths that are explicitly // write-allowed. The move-blocking rules above emit broad diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index bea85b285..28f1a5283 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -438,6 +438,15 @@ export const FilesystemConfigSchema = z.object({ 'Paths to re-allow reading within denied regions (takes precedence over denyRead). ' + 'Use with denyRead to deny a broad region then allow back specific subdirectories.', ), + denyReadAlways: z + .array(filesystemPathSchema) + .optional() + .describe( + 'Paths denied for reading that take precedence over allowRead. ' + + 'Use for credential-style patterns (e.g. "/**/.env*", "/**/credentials") ' + + 'inside a broadly-allowed directory. Patterns with a leading "/" match ' + + 'globally; otherwise they are CWD-relative.', + ), allowWrite: z .array(filesystemPathSchema) .describe('Paths allowed for writing'), diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 7c2d99db9..77d888b04 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -875,9 +875,25 @@ function getFsReadConfig(): FsReadRestrictionConfig { } } + // Process denyReadAlways paths (final denies that beat allowRead) + const denyAlwaysPaths: string[] = [] + for (const p of config.filesystem.denyReadAlways ?? []) { + const stripped = removeTrailingGlobSuffix(p) + if (getPlatform() === 'linux' && containsGlobChars(stripped)) { + const expanded = expandGlobPattern(p) + logForDebugging( + `[Sandbox] Expanded denyReadAlways glob pattern "${p}" to ${expanded.length} paths on Linux`, + ) + denyAlwaysPaths.push(...expanded) + } else { + denyAlwaysPaths.push(stripped) + } + } + return { denyOnly: denyPaths, allowWithinDeny: allowPaths, + denyAlways: denyAlwaysPaths, } } @@ -1258,9 +1274,27 @@ async function wrapWithSandbox( if (mitmCA) { expandedAllowRead.push(mitmCA.certPath, mitmCA.trustBundlePath) } + // denyReadAlways: paths denied even when they fall inside an allowRead + // region. Expanded with the same Linux-glob treatment as denyRead; the + // platform rule generators emit these as a final-deny pass that wins over + // allowWithinDeny. + const rawDenyReadAlways = + customConfig?.filesystem?.denyReadAlways ?? + config?.filesystem.denyReadAlways ?? + [] + const expandedDenyReadAlways: string[] = [] + for (const p of rawDenyReadAlways) { + const stripped = removeTrailingGlobSuffix(p) + if (getPlatform() === 'linux' && containsGlobChars(stripped)) { + expandedDenyReadAlways.push(...expandGlobPattern(p)) + } else { + expandedDenyReadAlways.push(stripped) + } + } readConfig = { denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, + denyAlways: expandedDenyReadAlways, } } diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index 57fca98e7..eb92e48f9 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -1,7 +1,7 @@ // Filesystem restriction configs (internal structures built from permission rules) /** - * Read restriction config using a "deny then allow-back" pattern. + * Read restriction config using a three-layer "deny → allow-back → final-deny" pattern. * * Semantics: * - `undefined` = no restrictions (allow all reads) @@ -9,13 +9,20 @@ * - `{denyOnly: [...paths]}` = deny reads from these paths, allow all others * - `{denyOnly: [...paths], allowWithinDeny: [...paths]}` = deny reads from * denyOnly paths, but re-allow reads within allowWithinDeny paths. - * allowWithinDeny takes precedence over denyOnly (most-specific rule wins). + * allowWithinDeny takes precedence over denyOnly. + * - `{denyAlways: [...paths]}` = deny reads from these paths even if they + * also match allowWithinDeny. Intended for credential-style patterns + * (e.g. "/**\/.env*") that should never be readable regardless of + * broader allow-rules. + * + * Rule priority (high → low): denyAlways > allowWithinDeny > denyOnly > default-allow. * * This is maximally permissive by default - only explicitly denied paths are blocked. */ export interface FsReadRestrictionConfig { denyOnly: string[] allowWithinDeny?: string[] + denyAlways?: string[] } /** diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index 30ee7bbe3..626f6214b 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -87,6 +87,25 @@ describe('Config Validation', () => { expect(result.success).toBe(false) }) + test('should validate config with denyReadAlways', () => { + const config = { + network: { + allowedDomains: [], + deniedDomains: [], + }, + filesystem: { + denyRead: ['/Users'], + allowRead: ['~/src'], + denyReadAlways: ['/**/.env*', '/**/credentials'], + allowWrite: [], + denyWrite: [], + }, + } + + const result = SandboxRuntimeConfigSchema.safeParse(config) + expect(result.success).toBe(true) + }) + test('should validate config with optional fields', () => { const config = { network: { From 4cc90d7ff5d220cf5b6085fdad8027571410f4e5 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Tue, 30 Jun 2026 12:15:18 -0700 Subject: [PATCH 03/18] chore(ltic): version 0.0.62-ltic.1 + read version from package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make `srt --version` a reliable fork identifier. Upstream read the version from `process.env.npm_package_version`, which is unset for a globally-installed binary, so `--version` always fell back to a hardcoded "1.0.0" — identical on upstream and this fork. Read the version from package.json at runtime instead, and stamp this branch with a distinctive `-ltic.1` suffix so users can confirm they are on the patched fork (allowAllDomains + denyReadAlways). Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src/cli.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c34295e1e..ec97f7dff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.62", + "version": "0.0.62-ltic.1", "description": "Anthropic Sandbox Runtime (ASRT) - A general-purpose tool for wrapping security boundaries around arbitrary processes", "type": "module", "main": "./dist/index.js", diff --git a/src/cli.ts b/src/cli.ts index 6a70da652..3eee47c4b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import * as readline from 'readline' import * as fs from 'fs' import * as path from 'path' import * as os from 'os' +import { fileURLToPath } from 'url' /** * Get default config path @@ -18,6 +19,30 @@ function getDefaultConfigPath(): string { return path.join(os.homedir(), '.srt-settings.json') } +/** + * Read the package version from package.json at runtime. + * + * `process.env.npm_package_version` is only set when run via an npm script, + * not for a globally-installed `srt` binary — so relying on it makes + * `srt --version` indistinguishable between builds. Reading package.json + * directly (it sits one level above dist/cli.js, and above src/cli.ts when + * run from source) makes the version a reliable build identifier. The LTIC + * fork carries a distinctive `-ltic.N` suffix so users can confirm they are + * on the patched fork (allowAllDomains + denyReadAlways) rather than upstream. + */ +function getPackageVersion(): string { + try { + const pkgPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'package.json', + ) + return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version || '0.0.0' + } catch { + return process.env.npm_package_version || '0.0.0' + } +} + /** * Create a minimal default config if no config file exists */ @@ -44,7 +69,7 @@ async function main(): Promise { .description( 'Run commands in a sandbox with network and filesystem restrictions', ) - .version(process.env.npm_package_version || '1.0.0') + .version(getPackageVersion()) // ── Windows install/uninstall ───────────────────────────────── // Self-elevating one-shot install (one UAC prompt). Also From d20d944afa57f1dc86b63126ac179f5a76c80e56 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Tue, 26 May 2026 17:53:43 -0700 Subject: [PATCH 04/18] feat(network): add allowAllDomains flag to bypass allowlist When `network.allowAllDomains: true`, filterNetworkRequest short-circuits to allow after the deniedDomains check runs, so explicit denies still take effect. Intended for environments where filesystem restrictions are the primary boundary and enumerating every reachable domain is impractical (e.g. opening web docs lookups in an otherwise locked-down sandbox). The flag is optional and additive: existing configs are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sandbox/sandbox-config.ts | 10 ++++++++++ src/sandbox/sandbox-manager.ts | 7 +++++++ test/config-validation.test.ts | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index bea85b285..ccbd13248 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -295,6 +295,16 @@ export const NetworkConfigSchema = z.object({ .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.', ), + allowAllDomains: z + .boolean() + .optional() + .describe( + 'If true, permit network egress to any domain. deniedDomains is still ' + + 'enforced (checked before the allow-all decision), so explicit denies ' + + 'still take effect. Intended for environments where filesystem ' + + 'restrictions are relied on as the primary boundary and the ' + + 'allowlist would otherwise need to enumerate the entire public web.', + ), allowUnixSockets: z .array(z.string()) .optional() diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 7c2d99db9..dbcbcbd39 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -191,6 +191,13 @@ async function filterNetworkRequest( } } + // allowAllDomains short-circuits the allowlist after denies are checked, + // so explicit deniedDomains entries still take effect. + if (config.network.allowAllDomains) { + logForDebugging(`Allowed by allowAllDomains: ${host}:${port}`) + return true + } + // Check allowed domains for (const allowedDomain of config.network.allowedDomains) { if (matchesDomainPattern(canonicalHost, allowedDomain)) { diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index 30ee7bbe3..978a63da2 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -87,6 +87,24 @@ describe('Config Validation', () => { expect(result.success).toBe(false) }) + test('should validate config with allowAllDomains flag', () => { + const config = { + network: { + allowedDomains: [], + deniedDomains: ['evil.com'], + allowAllDomains: true, + }, + filesystem: { + denyRead: [], + allowWrite: [], + denyWrite: [], + }, + } + + const result = SandboxRuntimeConfigSchema.safeParse(config) + expect(result.success).toBe(true) + }) + test('should validate config with optional fields', () => { const config = { network: { From 963439c268a99be4f9f9517573ae0c020c7ff2bf Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Wed, 27 May 2026 11:34:40 -0700 Subject: [PATCH 05/18] feat(filesystem): add denyReadAlways for denies that beat allowRead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read restrictions today have only two layers — denyRead and allowRead — with allowRead unconditionally winning. That makes it impossible to broadly allow a directory (e.g. ~/src for cross-project work) while still keeping credential-style files (.env, *.pem, ~/.aws/credentials) denied within it. The write side already has the symmetric capability via denyWrite, so reads were the odd one out. New optional `denyReadAlways: string[]` in FilesystemConfigSchema. On macOS it emits Seatbelt (deny file-read*) rules AFTER the allowRead loop so they take precedence (last-match-wins). On Linux it emits bubblewrap binds (--tmpfs for dirs, --ro-bind /dev/null for files) after the allowRead re-bind loop for the same effect. denyReadAlways paths are also passed to generateMoveBlockingRules so mv/rename cannot bypass the deny by relocating the file. Glob handling reuses the existing helpers — patterns work the same as denyRead. Patterns with a leading "/" match globally (e.g. "/**/.env*"); without it they are CWD-relative, matching existing denyRead behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/sandbox/linux-sandbox-utils.ts | 29 +++++++++++++++++++++++ src/sandbox/macos-sandbox-utils.ts | 37 +++++++++++++++++++++++++++--- src/sandbox/sandbox-config.ts | 9 ++++++++ src/sandbox/sandbox-manager.ts | 34 +++++++++++++++++++++++++++ src/sandbox/sandbox-schemas.ts | 11 +++++++-- test/config-validation.test.ts | 19 +++++++++++++++ 6 files changed, 134 insertions(+), 5 deletions(-) diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 91b5e4921..a6cc63eb3 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1115,6 +1115,35 @@ async function generateFilesystemArgs( maskedFiles.set(realPath, fakePath) } + // denyReadAlways: final-deny pass that takes precedence over allowRead. + // Emitted AFTER the allowRead re-bind loop above so these binds win. + // Bubblewrap applies binds in argument order — later binds shadow earlier + // ones for the same destination path. + for (const denyPath of readConfig?.denyAlways || []) { + const normalizedPath = normalizePathForSandbox(denyPath) + if (!fs.existsSync(normalizedPath)) { + logForDebugging( + `[Sandbox Linux] Skipping non-existent denyReadAlways path: ${normalizedPath}`, + ) + continue + } + const stat = fs.statSync(normalizedPath) + if (stat.isDirectory()) { + args.push('--tmpfs', normalizedPath) + logForDebugging( + `[Sandbox Linux] denyReadAlways masked directory with tmpfs: ${normalizedPath}`, + ) + } else { + const denyDest = resolveSymlinkDenyDest(normalizedPath) + args.push('--ro-bind', '/dev/null', denyDest) + maskedFiles.set(denyDest, '/dev/null') + maskedFiles.set(normalizedPath, '/dev/null') + logForDebugging( + `[Sandbox Linux] denyReadAlways masked file: ${normalizedPath}`, + ) + } + } + // Emitting denyWrite last means these ro-binds layer on top of any write // paths the denyRead loop just re-bound. Before this ordering, tmpfs over // an ancestor of cwd would wipe the .git/hooks protection. But skip any diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index a2935116a..3f0006a0f 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -318,18 +318,49 @@ function generateReadRules( } } + // denyAlways: final-deny pass that takes precedence over allowWithinDeny. + // Seatbelt is last-match-wins, so these deny rules emitted after the + // allowWithinDeny loop will fire when both match. Intended for + // credential-style globs (e.g. "/**/.env*") inside a broadly-allowed + // directory. + for (const pathPattern of config.denyAlways || []) { + const normalizedPath = normalizePathForSandbox(pathPattern) + + if (containsGlobChars(normalizedPath)) { + const regexPattern = globToRegex(normalizedPath) + rules.push( + `(deny file-read*`, + ` (regex ${escapePath(regexPattern)})`, + ` (with message "${logTag}"))`, + ) + } else { + rules.push( + `(deny file-read*`, + ` (subpath ${escapePath(normalizedPath)})`, + ` (with message "${logTag}"))`, + ) + } + } + // Allow stat/lstat on all directories so that realpath() can traverse // path components within denied regions. Without this, C realpath() fails // when resolving symlinks because it needs to lstat every intermediate // 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) { + if (config.denyOnly.length > 0 || (config.denyAlways || []).length > 0) { rules.push(`(allow file-read-metadata`, ` (vnode-type DIRECTORY))`) } - // Block file movement to prevent bypass via mv/rename - rules.push(...generateMoveBlockingRules(config.denyOnly || [], logTag)) + // Block file movement to prevent bypass via mv/rename. + // Include denyAlways paths so a compromised tool can't rename a credential + // file out of a denyAlways match. + rules.push( + ...generateMoveBlockingRules( + [...(config.denyOnly || []), ...(config.denyAlways || [])], + logTag, + ), + ) // Re-allow file-write-unlink / file-write-create for paths that are explicitly // write-allowed. The move-blocking rules above emit broad diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index bea85b285..28f1a5283 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -438,6 +438,15 @@ export const FilesystemConfigSchema = z.object({ 'Paths to re-allow reading within denied regions (takes precedence over denyRead). ' + 'Use with denyRead to deny a broad region then allow back specific subdirectories.', ), + denyReadAlways: z + .array(filesystemPathSchema) + .optional() + .describe( + 'Paths denied for reading that take precedence over allowRead. ' + + 'Use for credential-style patterns (e.g. "/**/.env*", "/**/credentials") ' + + 'inside a broadly-allowed directory. Patterns with a leading "/" match ' + + 'globally; otherwise they are CWD-relative.', + ), allowWrite: z .array(filesystemPathSchema) .describe('Paths allowed for writing'), diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 7c2d99db9..77d888b04 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -875,9 +875,25 @@ function getFsReadConfig(): FsReadRestrictionConfig { } } + // Process denyReadAlways paths (final denies that beat allowRead) + const denyAlwaysPaths: string[] = [] + for (const p of config.filesystem.denyReadAlways ?? []) { + const stripped = removeTrailingGlobSuffix(p) + if (getPlatform() === 'linux' && containsGlobChars(stripped)) { + const expanded = expandGlobPattern(p) + logForDebugging( + `[Sandbox] Expanded denyReadAlways glob pattern "${p}" to ${expanded.length} paths on Linux`, + ) + denyAlwaysPaths.push(...expanded) + } else { + denyAlwaysPaths.push(stripped) + } + } + return { denyOnly: denyPaths, allowWithinDeny: allowPaths, + denyAlways: denyAlwaysPaths, } } @@ -1258,9 +1274,27 @@ async function wrapWithSandbox( if (mitmCA) { expandedAllowRead.push(mitmCA.certPath, mitmCA.trustBundlePath) } + // denyReadAlways: paths denied even when they fall inside an allowRead + // region. Expanded with the same Linux-glob treatment as denyRead; the + // platform rule generators emit these as a final-deny pass that wins over + // allowWithinDeny. + const rawDenyReadAlways = + customConfig?.filesystem?.denyReadAlways ?? + config?.filesystem.denyReadAlways ?? + [] + const expandedDenyReadAlways: string[] = [] + for (const p of rawDenyReadAlways) { + const stripped = removeTrailingGlobSuffix(p) + if (getPlatform() === 'linux' && containsGlobChars(stripped)) { + expandedDenyReadAlways.push(...expandGlobPattern(p)) + } else { + expandedDenyReadAlways.push(stripped) + } + } readConfig = { denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, + denyAlways: expandedDenyReadAlways, } } diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index 57fca98e7..eb92e48f9 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -1,7 +1,7 @@ // Filesystem restriction configs (internal structures built from permission rules) /** - * Read restriction config using a "deny then allow-back" pattern. + * Read restriction config using a three-layer "deny → allow-back → final-deny" pattern. * * Semantics: * - `undefined` = no restrictions (allow all reads) @@ -9,13 +9,20 @@ * - `{denyOnly: [...paths]}` = deny reads from these paths, allow all others * - `{denyOnly: [...paths], allowWithinDeny: [...paths]}` = deny reads from * denyOnly paths, but re-allow reads within allowWithinDeny paths. - * allowWithinDeny takes precedence over denyOnly (most-specific rule wins). + * allowWithinDeny takes precedence over denyOnly. + * - `{denyAlways: [...paths]}` = deny reads from these paths even if they + * also match allowWithinDeny. Intended for credential-style patterns + * (e.g. "/**\/.env*") that should never be readable regardless of + * broader allow-rules. + * + * Rule priority (high → low): denyAlways > allowWithinDeny > denyOnly > default-allow. * * This is maximally permissive by default - only explicitly denied paths are blocked. */ export interface FsReadRestrictionConfig { denyOnly: string[] allowWithinDeny?: string[] + denyAlways?: string[] } /** diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index 30ee7bbe3..626f6214b 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -87,6 +87,25 @@ describe('Config Validation', () => { expect(result.success).toBe(false) }) + test('should validate config with denyReadAlways', () => { + const config = { + network: { + allowedDomains: [], + deniedDomains: [], + }, + filesystem: { + denyRead: ['/Users'], + allowRead: ['~/src'], + denyReadAlways: ['/**/.env*', '/**/credentials'], + allowWrite: [], + denyWrite: [], + }, + } + + const result = SandboxRuntimeConfigSchema.safeParse(config) + expect(result.success).toBe(true) + }) + test('should validate config with optional fields', () => { const config = { network: { From ff5e0fe503a4b6cb79af44a472ab1669ba393fa0 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Wed, 5 Aug 2026 11:19:29 -0700 Subject: [PATCH 06/18] feat(ltic): surface sandbox violations to the sandboxed agent Enable the seatbelt/seccomp log monitor in CLI mode (previously only proxy denials reached the store) and stream every violation line to ~/.local/state/srt/violations-.log, advertised to the child via SRT_VIOLATIONS_FILE. A Claude Code PostToolUse hook inside the sandbox tails the file and injects new lines as model context, so denials read as policy (with deniedDomainReasons) instead of mysterious 403/EPERM failures. The directory must be sandbox-readable but not writable so sandboxed code cannot forge lines into the agent's context. Co-Authored-By: Claude Fable 5 --- 0.62_to_0.70_NEW_FEATURES.md | 304 +++++++++++++++++++++++++++++++++++ src/cli.ts | 73 ++++++++- 2 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 0.62_to_0.70_NEW_FEATURES.md diff --git a/0.62_to_0.70_NEW_FEATURES.md b/0.62_to_0.70_NEW_FEATURES.md new file mode 100644 index 000000000..5ae426c84 --- /dev/null +++ b/0.62_to_0.70_NEW_FEATURES.md @@ -0,0 +1,304 @@ +# sandbox-runtime v0.0.62 → v0.0.70: What's New and What It Means for the ccx Configs + +Status: the `ltic-main` fork is now merged up to upstream **v0.0.70** (version +`0.0.70-ltic.1`, verify with `srt --version`). Both fork features — +`network.allowAllDomains` (PR #283, still open upstream) and +`filesystem.denyReadAlways` (PR #284, still open upstream) — survived the +update intact; this sync had **zero code conflicts** (only the version +stamp), so upstream drift remains cheap to carry. Both +`~/.srt-claude-denyall.json` and `~/.srt-claude-allowall.json` were +validated against the new schema and pass. **No config changes are +required.** The rest of this doc is what's newly possible and recommended. + +None of the v0.0.66 recommendations below have been adopted yet — they all +still apply, and the v0.0.70 additions compose with them (the biggest +payoffs come from combining the two, see "What this unlocks" at the end). + +--- + +## Part 1 — upstream v0.0.62 → v0.0.66 (133 commits, 4 releases) + +- **Credential masking, greatly expanded.** Sentinel substitution now covers + request **bodies** (JSON, form, multipart, gzip, chunked framing) and + **environment variables** (`credentials.envVars`), with optional structured + masking (`extract` regex), JWT decoding (`decode: "jwt"`), and per-claim + masking (`maskClaims`). The sandboxed process sees a structurally valid + fake credential; the TLS-terminating proxy swaps in the real one only on + egress to configured hosts. +- **Linux hardening** (not active on macOS): seccomp-notify violation + observer, forced `--unshare-user` + `--cap-drop ALL` in the secure bwrap + branch, symlink resolution for write-deny paths before computing bwrap + masks. +- **`network.tlsTerminate.extraCaCertPaths`** — new: PEM CA files appended to + the trust bundle srt injects into the sandbox (after the MITM CA and the + host roots). Only `CERTIFICATE` blocks are copied; missing files are + skipped safely. +- **Perf:** MITM CA/leaf signing moved from pure-JS node-forge to native + `crypto.sign()` — faster session startup when `tlsTerminate` is enabled. +- **Removed:** the `disableViolationMonitoring` config option. The schema + strips unknown keys silently, so configs that still set it won't error — + but drop it if present. (The ccx configs never used it.) +- **Misc:** `GRPC_PROXY` now advertises the HTTP CONNECT proxy instead of + socks5h; large Windows (srt-win) overhaul — both irrelevant to the macOS + ccx setup. + +### Recommended: mask `GH_TOKEN` instead of passing it in plain + +Today `_ccx_run` extracts the gh token from the keychain and exports it as +`GH_TOKEN` into the sandbox. Any sandboxed process can read it, and because +the configs run with `allowAllDomains: true`, it could be sent anywhere. +The env-var masking closes exactly this hole: + +```json +"credentials": { + "envVars": [ + { + "name": "GH_TOKEN", + "mode": "mask", + "injectHosts": ["github.com", "*.github.com"] + } + ] +} +``` + +The sandbox sees a length-matched fake token; the proxy substitutes the real +one only for GitHub hosts — in headers *and* request bodies. A process that +exfiltrates `$GH_TOKEN` to any other host leaks a worthless sentinel. + +#### Caveats — read before enabling + +1. **`injectHosts` is mandatory in these configs.** When unset it defaults + to `network.allowedDomains`, which is `[]` here because the fork's + `allowAllDomains` flag does the allowing. Without an explicit + `injectHosts`, the real token is injected **nowhere** and `gh` silently + fails auth. +2. **Requires `network.tlsTerminate`.** Substitution happens inside the + TLS-terminated stream (plain-HTTP substitution is off by default via + `allowPlaintextInject`). Enabling it means the sandbox must trust srt's + MITM CA — see next section. +3. **`enableWeakerNetworkIsolation: true` must stay.** `gh` is a Go binary + and still needs `com.apple.trustd.agent` for TLS verification on macOS. +4. Tools that bypass the proxy send the fake token and fail auth — a + functionality caveat, not a leak (the sentinel is worthless). + +### Enabling `tlsTerminate` may retire the CA-bundle staging hack + +`_ccx_run` currently copies `/etc/ssl/cert.pem` to a `.crt` path and exports +`SSL_CERT_FILE`/`CURL_CA_BUNDLE`/`CARGO_HTTP_CAINFO`/`GIT_SSL_CAINFO`/ +`REQUESTS_CA_BUNDLE`, because the `/**/*.pem` deny glob eats the system +bundle. With `tlsTerminate` enabled, srt **builds and injects its own trust +bundle** (MITM CA + host roots + any `extraCaCertPaths`) and sets those same +env vars itself, with the bundle at an allowed temp path the pem-glob +doesn't cover. The zshrc comment anticipating "srt's MITM CA must be +appended to this bundle" is thus handled natively — the manual `cp` and env +exports are likely removable. **Smoke-test before deleting**: run a +sandboxed `gh api user`, `curl https://example.com`, and a `cargo` fetch; +srt's env injection should win, but confirm the wrapper's own exports don't +shadow it. + +`extraCaCertPaths` is also the supported way to trust site-local/internal +CAs inside the sandbox if that ever comes up on the managed machine. + +### Explicitly *not* worth adopting from this range (macOS) + +- **`credentials.files` masking** — whole-file masking is Linux-only and + degrades to plain `deny` on macOS (SBPL cannot redirect reads). The + existing `denyReadAlways` globs already provide everything it could offer + here. This also means the keychain→`~/.claude/.credentials.json` seeding + scheme in `_ccx_run` remains the right approach; file masking cannot + replace it on a Mac. +- **Violation-monitoring / seccomp options** — Linux-only. +- **`windows` / `srtWin` config blocks** — N/A. +- **Symlinked-deny-path resolution, native CA signing, `GRPC_PROXY`** — + automatic behavior changes with no config surface. + +--- + +## Part 2 — upstream v0.0.66 → v0.0.70 (42 commits, 4 releases) + +### `:port` suffixes on `allowedDomains` / `deniedDomains` + +Domain entries may now carry an optional destination-port suffix: +`"api.example.com:443"`, `"github.com:22"`, and — in `deniedDomains` only — +port-scoped deny-alls like `"*:22"`. An entry without a port matches any +port, exactly as before. This is the first way to say "this host, but only +this protocol" (or "any host, except this protocol") in srt config. + +This composes cleanly with the fork: **`allowAllDomains` short-circuits the +allowlist *after* `deniedDomains` is checked** +(`src/sandbox/sandbox-manager.ts` — the deny loop runs first, port-aware), +so port-scoped denies are fully enforced in the default-allow ccx setup. + +### `deniedDomainReasons` — explain a deny to the model + +New optional `network.deniedDomainReasons` map, keyed by the **exact** +`deniedDomains` entry string: + +```json +"network": { + "deniedDomains": ["gist.github.com"], + "deniedDomainReasons": { + "gist.github.com": "Creating gists is blocked in this sandbox (exfiltration risk). Use a file in the repo instead." + } +} +``` + +When that entry denies a connection, the reason is recorded on the +violation line instead of a generic "host is on the deny list". + +**Honest caveat for the ccx setup:** the reason travels in the violation +*store*, which is an embedder API — `annotateStderrWithSandboxFailures` +appends a `` block to a wrapped command's stderr when +srt is embedded as a library (as Claude Code's native sandboxing does). +The `srt` CLI wrapper that ccx uses spawns the child directly and never +prints the store, and the sandboxed client itself still sees only a generic +`403 Connection blocked by network allowlist`. So today this is +self-documenting config + future-proofing, not model-visible feedback. Two +ways to make it pay off are in Part 3. + +### Proxy denials are now real violations + +Previously a network deny was only a debug-log line; now every proxy denial +(HTTP and SOCKS) is recorded in the `SandboxViolationStore` with the denied +`host:port` and reason. Hardening came with it: + +- Violation lines **honor `ignoreViolations`** (the same command-pattern → + ignore map that already silenced filesystem noise), so an expected, + recurring deny can be kept out of the report. +- URLs on violation lines are **redacted**: query strings collapse to `?…` + and `user:pass@` userinfo is dropped — runtime-interpolated secrets + (api_key=, signed URLs) can't leak into a transcript via the violation + block. Lines are also forced single-line and `<`/`>`-free so they can't + break the block's framing. + +### Per-exec `commandLabel` (embedder API) + +`SandboxManager` exec options gained `commandLabel` — a display label used +for violation attribution instead of the raw command string (which may be +long or contain decoded proxy internals). N/A for the CLI wrapper today; +relevant if ccx ever embeds the library. + +### AWS SigV4 re-signing (`credentials.awsPairs`, `credentials.sigv4`) + +The masking story from 0.66 had a gap for AWS: SigV4 signatures are HMACs +computed *from* the secret key, so a client signing with a masked secret +produced signatures no header substitution could repair. The proxy can now +**re-sign** such requests with the real credentials. The conventional +`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_SESSION_TOKEN` trio pairs +automatically when masked whole-value; `awsPairs` names non-standard +variables; `sigv4` sets deny/passthrough policy for the un-re-signable +shapes (streaming uploads, presigned URLs, SigV4A). **N/A for ccx until an +AWS credential enters the sandbox** — but it means AWS creds can get the +same treatment as `GH_TOKEN` if that day comes. + +### `git.safeDirectories` + +Injects `safe.directory` entries via `GIT_CONFIG_*` env vars, without +granting write. Solves git's "dubious ownership" failures where the sandbox +runs as a different user — a Linux (bwrap user-namespace) and Windows +(sandbox user) problem. **macOS sandbox-exec keeps your uid, so ccx never +hits this**; nothing to adopt. + +### Robustness fixes (automatic, no config surface) + +- **Trailing slashes on filesystem entries are now normalized** at the + `normalizePathForSandbox` chokepoint: a non-glob spelling like + `"/Users/"` used to silently produce a weaker/non-matching rule; now it + equals `"/Users"`. The ccx configs were already slash-free, so this + changes nothing there — but hand-edits are now safer. +- **Client-abort hardening** in the proxy: a sandboxed client disconnecting + mid-request no longer risks wedged sockets/unhandled errors. +- Linux-only: creation stubs under a read-only denied directory are skipped + correctly; the sandbox now starts when the seccomp helper binary is + execute-only. +- Windows: another large srt-win overhaul; `windows.srtWin.path` is now + required to use the Windows sandbox. N/A. + +--- + +## Part 3 — what this unlocks for the ccx configs + +The denyall config's intent is: **default-allow network with targeted +exfiltration denies** (fork `allowAllDomains` + `gist.github.com`), +**secrets unreadable even under broad read grants** (fork `denyReadAlways` +globs), and minimal friction/false positives for everything else. Against +that intent, ranked by value: + +### 1. Widen the exfiltration denylist cheaply (port suffixes) + +`gist.github.com` blocks one paste-site; port-scoped denies let you cut +whole *protocols* without touching the default-allow posture: + +```json +"deniedDomains": ["gist.github.com", "*:25", "*:587", "*:465"] +``` + +blocks direct SMTP from the sandbox (a classic quiet-exfil channel that +`allowAllDomains` currently permits). If/when `GH_TOKEN` masking is on, +`"github.com:22"` + `"ssh.github.com"` is worth considering too: it forces +GitHub traffic onto https where the proxy can see it and where the masked +token is the only credential — over SSH the proxy substitutes nothing, so +an agent quietly switching a remote to SSH bypasses the whole masking +story. (Leave SSH alone until then; today it's a legitimate path.) + +### 2. Adopt the 0.66 combo — it just got better + +`tlsTerminate` + `GH_TOKEN` masking (Part 1) remains **the single biggest +risk reduction and simplification available**: it retires the CA-staging +hack in `_ccx_run` (simpler wrapper, fewer env vars to keep in sync) and +turns the plaintext token into a sentinel. The 0.70 changes strengthen the +same path: the proxy that does the masking is now more robust under client +aborts, and its denials are observable instead of silent. + +### 3. Make denials visible to the agent — IMPLEMENTED (2026-08-05) + +Recording proxy denials in the violation store is the foundation the ccx +setup was missing: a blocked gist push used to look like a mysterious 403 +to the agent, which reads as a *false positive* ("the network is broken") +rather than policy. This is now closed end-to-end: + +- **srt side (fork patch in `src/cli.ts`):** the CLI enables the seatbelt + log monitor (previously off in CLI mode, so filesystem denials were + never collected) and streams every violation line to + `~/.local/state/srt/violations-.log`, advertised to the sandboxed + child via `SRT_VIOLATIONS_FILE`. That directory is readable but **not + writable** under both ccx configs — sandboxed code cannot forge + violation lines into an agent's context. +- **Claude Code side (no fork drift):** a PostToolUse(Bash) hook + (`~/.claude/hooks/srt-violations.mjs`, registered in + `~/.claude/settings.json`) reads new lines since its last offset and + injects them as `additionalContext` wrapped in ``, + with a preamble explaining these are policy blocks. Outside an srt + sandbox the env var is unset and the hook is a no-op. +- **Config side:** both ccx configs now carry `deniedDomainReasons` for + the gist entry and an `ignoreViolations` block filtering benign macOS + noise (sysctl kern.*, GlobalPreferences plists, configd mach-lookup) — + proxy deny lines honor `ignoreViolations` as of 0.70. + +Verified end-to-end: a denied `https://gist.github.com` fetch logs the +deny with the configured reason instantly; a denied `.env` read logs the +exact path after ~1–2s of `log stream` latency (a trailing violation can +therefore slip past a session that exits immediately — irrelevant for +interactive sessions, where the next Bash command's hook picks it up). + +### 4. Nothing to simplify away yet + +Nothing in 0.67–0.70 replaces an existing entry in either config, and +nothing upstream supersedes the fork features: `allowedDomains` still +rejects bare `*` (so PR #283 `allowAllDomains` is still needed for +default-allow) and deny-beats-allow read globs remain fork-only (PR #284; +upstream's credential-file masking is still Linux-only). Both PRs remain +open upstream. The configs as written are already minimal for what they +express — the simplification wins live in `_ccx_run` (CA staging, see #2), +not in the JSON. + +### Suggested next step + +Copy `.srt-claude-denyall.json`, add the `credentials` block + +`"tlsTerminate": {}` from Part 1 and the `deniedDomainReasons` + +port-scoped denies from Part 3, and run `ccx` against it. Verify: `gh api +user` succeeds, `env | grep GH_TOKEN` shows a fake value inside the +sandbox, TLS tools (curl, git, cargo, uv) still verify, and a deliberate +`curl https://gist.github.com` / SMTP connection is denied. If that passes, +apply to `.srt-claude-allowall.json`, drop the CA-staging lines from +`_ccx_run`, and decide on the CLI violation-dump patch. diff --git a/src/cli.ts b/src/cli.ts index 4b7cfbec7..a62ddd3f9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,6 +43,65 @@ function getPackageVersion(): string { } } +/** + * LTIC fork: stream sandbox violations to a file the sandboxed child can + * read. + * + * The violation store lives in this host-side process; the sandboxed agent + * only ever sees a generic 403 / EPERM when it trips a rule. Appending each + * violation line to a host-written file (advertised to the child via + * SRT_VIOLATIONS_FILE) lets tooling inside the sandbox — e.g. a Claude Code + * PostToolUse hook — surface denial reasons to the agent. + * + * The file lives under ~/.local/state/srt, which the sandbox config should + * grant read but NOT write: violation lines flow into an agent's context, + * so sandboxed code must not be able to forge them. + */ +function startViolationsFile(): string | undefined { + try { + const dir = path.join(os.homedir(), '.local', 'state', 'srt') + fs.mkdirSync(dir, { recursive: true }) + + // Prune logs from long-dead sessions so the directory doesn't grow + // without bound. + const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000 + for (const name of fs.readdirSync(dir)) { + if (!/^violations-\d+\.log$/.test(name)) continue + const p = path.join(dir, name) + try { + if (fs.statSync(p).mtimeMs < weekAgo) fs.unlinkSync(p) + } catch { + // Another session may have pruned it first. + } + } + + const file = path.join(dir, `violations-${process.pid}.log`) + fs.writeFileSync(file, '') + + const store = SandboxManager.getSandboxViolationStore() + // The store notifies synchronously on every addViolation, so each + // callback carries exactly the events past `seen`; the initial + // subscribe() replay is skipped because `seen` already covers it. + let seen = store.getTotalCount() + store.subscribe(() => { + const total = store.getTotalCount() + if (total <= seen) return + const fresh = store.getViolations().slice(-(total - seen)) + seen = total + const text = fresh + .map(v => `${v.timestamp.toISOString()} ${v.line}`) + .join('\n') + fs.appendFile(file, text + '\n', () => {}) + }) + return file + } catch (error) { + logForDebugging( + `Failed to set up violations file: ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined + } +} + /** * Create a minimal default config if no config file exists */ @@ -232,9 +291,19 @@ async function main(): Promise { } } - // Initialize sandbox with config + // Initialize sandbox with config. LTIC fork: enable the log + // monitor so filesystem (seatbelt/seccomp) denials reach the + // violation store — the CLI otherwise only collects proxy denials. logForDebugging('Initializing sandbox...') - await SandboxManager.initialize(runtimeConfig) + await SandboxManager.initialize(runtimeConfig, undefined, true) + + // LTIC fork: expose violations to the sandboxed child (see + // startViolationsFile). The child inherits process.env. + const violationsFile = startViolationsFile() + if (violationsFile) { + process.env.SRT_VIOLATIONS_FILE = violationsFile + logForDebugging(`Violations file: ${violationsFile}`) + } // Set up control fd for dynamic config updates if specified let controlReader: readline.Interface | null = null From 0064f4555d5f863de1bd0733d1a03929b2bb0f46 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Thu, 6 Aug 2026 09:46:23 -0700 Subject: [PATCH 07/18] feat(ltic): warn on unrecognized settings keys; version 0.0.70-ltic.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zod strips unknown config keys silently, so a mistyped fork key (allowAllDomainz) or a config/build mismatch validated cleanly while the intended grant or restriction was silently lost — the exact footgun that makes stock srt turn an allowAllDomains config into deny-all with no feedback. The settings loader now walks the parsed JSON against the schema and names every unrecognized key in a stderr warning at launch. Tests pin both directions: all keys the ccx configs use (including fork keys allowAllDomains and denyReadAlways) are recognized, and unknown keys are reported with full dot-paths. --- package-lock.json | 4 +- package.json | 2 +- src/utils/config-loader.ts | 86 +++++++++++++++++++++ test/config-unrecognized-keys.test.ts | 104 ++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 test/config-unrecognized-keys.test.ts diff --git a/package-lock.json b/package-lock.json index 98c3dbe0d..473c7e671 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.1", + "version": "0.0.70-ltic.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.1", + "version": "0.0.70-ltic.2", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", diff --git a/package.json b/package.json index 41764ce49..952c9f70b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.1", + "version": "0.0.70-ltic.2", "description": "Anthropic Sandbox Runtime (ASRT) - A general-purpose tool for wrapping security boundaries around arbitrary processes", "type": "module", "main": "./dist/index.js", diff --git a/src/utils/config-loader.ts b/src/utils/config-loader.ts index 3959dbb58..dbc96ac93 100644 --- a/src/utils/config-loader.ts +++ b/src/utils/config-loader.ts @@ -1,9 +1,92 @@ import * as fs from 'fs' +import { z } from 'zod' import { SandboxRuntimeConfigSchema, type SandboxRuntimeConfig, } from '../sandbox/sandbox-config.js' +/** + * LTIC fork: surface unrecognized settings keys instead of silently + * stripping them. + * + * Zod's default object behavior drops unknown keys, so a mistyped key + * (`allowAllDomainz`) or a key from a different srt build (fork keys like + * `network.allowAllDomains` on stock srt, or newer upstream keys on an + * older build) validates "successfully" while the intended restriction or + * grant is silently lost. A network config that looks default-allow can + * actually be deny-all. These helpers walk the parsed JSON against the + * schema and name every key the validator does not know, so the mismatch + * is loud at startup rather than discovered from sandbox behavior. + */ +function unwrapSchema(schema: z.ZodTypeAny): z.ZodTypeAny { + for (;;) { + if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) { + schema = schema.unwrap() as z.ZodTypeAny + } else if (schema instanceof z.ZodDefault) { + schema = schema.removeDefault() as z.ZodTypeAny + } else if (schema instanceof z.ZodEffects) { + schema = schema.innerType() as z.ZodTypeAny + } else { + return schema + } + } +} + +function collectUnrecognized( + value: unknown, + schema: z.ZodTypeAny, + path: string, + out: string[], +): void { + const s = unwrapSchema(schema) + if ( + s instanceof z.ZodObject && + value !== null && + typeof value === 'object' && + !Array.isArray(value) + ) { + const shape = s.shape as Record + for (const [key, child] of Object.entries(value)) { + const childPath = path ? `${path}.${key}` : key + const childSchema = shape[key] + if (childSchema) { + collectUnrecognized(child, childSchema, childPath, out) + } else { + out.push(childPath) + } + } + } else if (s instanceof z.ZodArray && Array.isArray(value)) { + for (const [i, item] of value.entries()) { + collectUnrecognized(item, s.element as z.ZodTypeAny, `${path}[${i}]`, out) + } + } + // Unions, records, and primitives: keys can't be attributed to a single + // shape, so they are left to the schema itself (the union members that + // need it are .strict() and fail the parse outright). +} + +/** + * Names every key in a parsed settings object that the config schema does + * not recognize (dot-separated paths, e.g. "network.allowAllDomainz"). + * Exported for tests. + */ +export function collectUnrecognizedConfigKeys(parsed: unknown): string[] { + const out: string[] = [] + collectUnrecognized(parsed, SandboxRuntimeConfigSchema, '', out) + return out +} + +function warnUnrecognizedKeys(parsed: unknown, source: string): void { + const unknown = collectUnrecognizedConfigKeys(parsed) + if (unknown.length === 0) return + console.error( + `[sandbox-runtime] WARNING: unrecognized settings key(s) in ${source}: ` + + `${unknown.join(', ')}. Unrecognized keys are IGNORED — the ` + + 'restriction or grant they were meant to express is not in effect. ' + + 'Check for typos or an srt build mismatch (`srt --version`).', + ) +} + /** * Parse and validate sandbox configuration from a string * Used for parsing config from control fd (JSON lines protocol) @@ -21,6 +104,7 @@ export function loadConfigFromString( if (!result.success) { return null } + warnUnrecognizedKeys(parsed, 'control-fd config update') return result.data } catch { return null @@ -55,6 +139,8 @@ export function loadConfig(filePath: string): SandboxRuntimeConfig | null { return null } + warnUnrecognizedKeys(parsed, filePath) + return result.data } catch (error) { // Log parse errors to help users debug invalid config files diff --git a/test/config-unrecognized-keys.test.ts b/test/config-unrecognized-keys.test.ts new file mode 100644 index 000000000..be692d42e --- /dev/null +++ b/test/config-unrecognized-keys.test.ts @@ -0,0 +1,104 @@ +import { describe, test, expect } from 'bun:test' +import { collectUnrecognizedConfigKeys } from '../src/utils/config-loader.js' + +// LTIC fork: the settings loader warns on keys the schema does not +// recognize instead of letting zod strip them silently. These tests pin +// down both directions: every key the fork's configs rely on (including +// the fork-only keys) is recognized, and unknown/mistyped keys are named. +describe('collectUnrecognizedConfigKeys', () => { + const base = { + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + } + + test('recognizes every key used by the ccx configs, including fork keys', () => { + const config = { + network: { + allowedDomains: ['example.com'], + deniedDomains: ['gist.github.com', '*:25'], + // Fork key (upstream PR #283) + allowAllDomains: true, + deniedDomainReasons: { + 'gist.github.com': 'blocked by sandbox policy', + }, + allowUnixSockets: ['/var/run/docker.sock'], + allowLocalBinding: true, + }, + filesystem: { + denyRead: ['/Users'], + allowRead: ['~/src'], + // Fork key (upstream PR #284) + denyReadAlways: ['/**/.env*', '/**/credentials'], + allowWrite: ['/tmp'], + denyWrite: [], + }, + ignoreViolations: { + '*': ['sysctl-read kern.'], + }, + enableWeakerNetworkIsolation: true, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([]) + }) + + test('flags a mistyped fork key in network', () => { + const config = { + ...base, + network: { ...base.network, allowAllDomainz: true }, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([ + 'network.allowAllDomainz', + ]) + }) + + test('flags a mistyped fork key in filesystem', () => { + const config = { + ...base, + filesystem: { ...base.filesystem, denyReadAlway: ['/**/.env*'] }, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([ + 'filesystem.denyReadAlway', + ]) + }) + + test('flags unknown top-level keys', () => { + const config = { ...base, allowEverything: true } + + expect(collectUnrecognizedConfigKeys(config)).toEqual(['allowEverything']) + }) + + test('flags nested unknown keys inside tlsTerminate', () => { + const config = { + ...base, + network: { ...base.network, tlsTerminate: { caCert: '/etc/ca.crt' } }, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([ + 'network.tlsTerminate.caCert', + ]) + }) + + test('does not flag record keys (ignoreViolations command patterns)', () => { + const config = { + ...base, + ignoreViolations: { 'git push': ['/usr/bin/nc'] }, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([]) + }) + + test('reports multiple unknown keys with full paths', () => { + const config = { + ...base, + network: { ...base.network, allowedDomainz: [] }, + filesystem: { ...base.filesystem, denyReed: [] }, + } + + expect(collectUnrecognizedConfigKeys(config).sort()).toEqual([ + 'filesystem.denyReed', + 'network.allowedDomainz', + ]) + }) +}) From 041568c83de96b8cd435e2e5933ca1449dc38fb0 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Thu, 6 Aug 2026 09:49:57 -0700 Subject: [PATCH 08/18] fix(network): make credential-masking validation aware of allowAllDomains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The injectHosts ⊆ allowedDomains reachability check predates allowAllDomains: with the flag on, every host is reachable, but the validator still demanded allowlist entries the flag makes meaningless — the documented masking recipe (empty allowlist + injectHosts toward GitHub) failed to load without padding allowedDomains. Skip the subset check when allowAllDomains is set. Conversely, add the missing guard for the trap that combination creates: a masked credential with no injectHosts defaults to allowedDomains, which under allowAllDomains is typically empty — masked but injected nowhere, a silent auth failure. That spelling is now a validation error asking for explicit injectHosts. The excludeDomains contradiction checks and the explicit-empty-injectHosts rejection are unchanged. --- src/sandbox/sandbox-config.ts | 28 +++++++++ test/config-validation.test.ts | 104 +++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index f22ef1184..3ba31c70e 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -1130,10 +1130,16 @@ export const SandboxRuntimeConfigSchema = z // Host-scoped view of the allowlist (":port" suffixes dropped) — // injection is per-host, not per-port. const allowedHosts = cfg.network.allowedDomains.map(stripDomainPatternPort) + // allowAllDomains makes every host reachable regardless of the + // allowlist, so the reachability requirement on injectHosts is + // vacuous — without this, the masking recipe would demand allowlist + // entries that the flag makes meaningless. + const allowAll = cfg.network.allowAllDomains === true const checkSubset = ( hosts: readonly string[], path: (string | number)[], ) => { + if (allowAll) return for (const [i, host] of hosts.entries()) { if (!isInjectHostCoveredByAllowedDomains(host, allowedHosts)) { ctx.addIssue({ @@ -1163,6 +1169,28 @@ export const SandboxRuntimeConfigSchema = z } if (entry.mode !== 'mask') return hasMasked = true + // Runtime default injectHosts is network.allowedDomains. Under + // allowAllDomains the allowlist no longer gates reachability and is + // typically empty — the default then resolves to no hosts at all + // and the credential would be masked but never injected (silent + // auth failure inside the sandbox). Require explicit injectHosts. + if ( + allowAll && + entry.injectHosts === undefined && + allowedHosts.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path, + message: + `This masked credential has no injectHosts, so it defaults to ` + + `network.allowedDomains — which is empty. With ` + + `network.allowAllDomains the allowlist does not gate ` + + `reachability, so the default would inject the credential ` + + `nowhere. List the hosts that should receive the real value ` + + `in injectHosts.`, + }) + } // Credential substitution only runs on the TLS-terminated path, so a // host covered by tlsTerminate.excludeDomains can never receive the // real value — the upstream sees the placeholder. Reject the diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index 7d36276c0..8d18ca38d 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -1955,3 +1955,107 @@ describe('Config Validation', () => { }) }) }) + +describe('allowAllDomains × credential masking', () => { + const base = { + network: { allowedDomains: [], deniedDomains: [], allowAllDomains: true }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + } + + test('accepts injectHosts not covered by allowedDomains', () => { + // The docs' masking recipe: default-allow networking, empty + // allowlist, credential injected only toward GitHub. Without the + // allowAll bypass the subset check would demand github.com in + // allowedDomains — entries the flag makes meaningless. + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { ...base.network, tlsTerminate: {} }, + credentials: { + envVars: [ + { + name: 'GH_TOKEN', + mode: 'mask', + injectHosts: ['github.com', '*.github.com'], + }, + ], + }, + }) + expect(result.success).toBe(true) + }) + + test('without allowAllDomains the same config is still rejected', () => { + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { allowedDomains: [], deniedDomains: [], tlsTerminate: {} }, + credentials: { + envVars: [ + { name: 'GH_TOKEN', mode: 'mask', injectHosts: ['github.com'] }, + ], + }, + }) + expect(result.success).toBe(false) + }) + + test('rejects a masked credential with no injectHosts and an empty allowlist', () => { + // Runtime default injectHosts = allowedDomains = [] — the credential + // would be masked but injected nowhere, a silent auth failure. + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { ...base.network, tlsTerminate: {} }, + credentials: { + envVars: [{ name: 'GH_TOKEN', mode: 'mask' }], + }, + }) + expect(result.success).toBe(false) + if (!result.success) { + const messages = result.error.issues.map(i => i.message).join('\n') + expect(messages).toContain('inject the credential nowhere') + } + }) + + test('accepts a masked credential with no injectHosts when allowedDomains is non-empty', () => { + // Default injectHosts = allowedDomains still names real hosts, so the + // credential is injectable — same as without allowAllDomains. + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { + ...base.network, + allowedDomains: ['api.github.com'], + tlsTerminate: {}, + }, + credentials: { + envVars: [{ name: 'GH_TOKEN', mode: 'mask' }], + }, + }) + expect(result.success).toBe(true) + }) + + test('still rejects injectHosts entirely covered by tlsTerminate.excludeDomains', () => { + // allowAllDomains does not rescue the terminated-path contradiction: + // an excluded host never gets injection regardless of reachability. + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { + ...base.network, + tlsTerminate: { excludeDomains: ['github.com'] }, + }, + credentials: { + envVars: [ + { name: 'GH_TOKEN', mode: 'mask', injectHosts: ['github.com'] }, + ], + }, + }) + expect(result.success).toBe(false) + }) + + test('still rejects an explicitly empty injectHosts', () => { + const result = SandboxRuntimeConfigSchema.safeParse({ + ...base, + network: { ...base.network, tlsTerminate: {} }, + credentials: { + envVars: [{ name: 'GH_TOKEN', mode: 'mask', injectHosts: [] }], + }, + }) + expect(result.success).toBe(false) + }) +}) From ef70de638b19aaf707194d88e404d901d8b58fd2 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Thu, 6 Aug 2026 09:51:50 -0700 Subject: [PATCH 09/18] chore(ltic): version 0.0.70-ltic.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 473c7e671..899f34570 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.2", + "version": "0.0.70-ltic.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.2", + "version": "0.0.70-ltic.3", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", diff --git a/package.json b/package.json index 952c9f70b..35d589eb5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.2", + "version": "0.0.70-ltic.3", "description": "Anthropic Sandbox Runtime (ASRT) - A general-purpose tool for wrapping security boundaries around arbitrary processes", "type": "module", "main": "./dist/index.js", From 075942144089e2dd20199c8fc6cead72bb78ef15 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Thu, 6 Aug 2026 11:02:17 -0700 Subject: [PATCH 10/18] fix(macos): auth-capable GIT_SSH_COMMAND via socat when present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS GIT_SSH_COMMAND used BSD nc for its ProxyCommand, and nc only speaks the SOCKS5 no-auth method. When SRT owns the proxy it always sets a per-session auth token, so every sandboxed git-over-ssh attempt died at the SOCKS handshake ("authentication method negotiation failed") — the code comment acknowledged this and punted users to git-over-https. Linux never had the problem because it emits socat - PROXY:localhost:%h:%p,proxyport=,proxyauth=: — an HTTP CONNECT tunnel that can present Proxy-Authorization. The mux serves HTTP CONNECT on the same port on macOS, and the terminate proxy already sniffs for a ClientHello before MITM-ing, so non-TLS SSH bytes pass through opaquely (pinned by connect-non-tls.test.ts). macOS now emits the identical socat spelling whenever socat is found on PATH (whichSync, per generateProxyEnvVars call — the wrapper already probes PATH per wrap for the shell) and httpProxyPort is set, keeping the nc spelling as the fallback for hosts without socat. socat stays an optional macOS dependency, unlike Linux where it is mandatory. Tests pin all four macOS shapes: socat+auth, socat+per-command attribution username (userRaw, not percent-encoded — the Basic header takes it verbatim), socat without auth, and the nc fallback. Also fixes stale "Linux-only" wording in http-proxy.ts / connect-non-tls.test.ts and the SOCKS5 misnomer in the linux-sandbox-utils docblock. --- package-lock.json | 4 +- package.json | 2 +- src/sandbox/http-proxy.ts | 2 +- src/sandbox/linux-sandbox-utils.ts | 2 +- src/sandbox/sandbox-utils.ts | 28 ++++++++--- test/sandbox/connect-non-tls.test.ts | 2 +- test/sandbox/proxy-env-vars.test.ts | 70 +++++++++++++++++++++++++++- 7 files changed, 97 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 899f34570..d31f00f0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.3", + "version": "0.0.70-ltic.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.3", + "version": "0.0.70-ltic.4", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", diff --git a/package.json b/package.json index 35d589eb5..a297a60a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.3", + "version": "0.0.70-ltic.4", "description": "Anthropic Sandbox Runtime (ASRT) - A general-purpose tool for wrapping security boundaries around arbitrary processes", "type": "module", "main": "./dist/index.js", diff --git a/src/sandbox/http-proxy.ts b/src/sandbox/http-proxy.ts index 0f695a3da..1c94c78b2 100644 --- a/src/sandbox/http-proxy.ts +++ b/src/sandbox/http-proxy.ts @@ -294,7 +294,7 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server { ) { if (clientGone) return // We can only terminate TLS. CONNECT also carries non-TLS streams — - // notably SSH on Linux, where the sandbox's own GIT_SSH_COMMAND + // notably SSH, where the sandbox's own GIT_SSH_COMMAND // routes `ssh` through this proxy via `socat - PROXY:`. Send 200 so // the client transmits its first bytes, sniff for a ClientHello, and // only terminate if it is one. Non-TLS falls through to the opaque diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index c4e27d180..a9f9f3378 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -590,7 +590,7 @@ export function checkLinuxDependencies( * * 3. Configure environment: * - HTTP_PROXY=http://localhost:3128 for HTTP/HTTPS tools - * - GIT_SSH_COMMAND with socat for SSH through SOCKS5 + * - GIT_SSH_COMMAND with socat for SSH through HTTP CONNECT * * LIMITATION: Unlike macOS sandbox which can enforce domain-based allowlists at the kernel level, * Linux's --unshare-net provides only all-or-nothing network isolation. Domain filtering happens diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 933585175..19af386ea 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -3,6 +3,7 @@ import * as path from 'path' import * as fs from 'fs' import { getPlatform } from '../utils/platform.js' import { logForDebugging } from '../utils/debug.js' +import { whichSync } from '../utils/which.js' /** * Dangerous files that should be protected from writes. @@ -582,12 +583,27 @@ export function generateProxyEnvVars( const sshMuxOverride = '-o ControlMaster=no -o ControlPath=none' const platform = getPlatform() if (platform === 'macos') { - // macOS: use BSD nc SOCKS5 proxy support (-X 5 -x). nc has no SOCKS5 - // auth, so when proxyAuthToken is set, git-over-ssh fails at the SOCKS - // handshake — use git-over-https (HTTP_PROXY carries the credential). - envVars.push( - `GIT_SSH_COMMAND=ssh ${sshMuxOverride} -o ProxyCommand='nc -X 5 -x localhost:${socksProxyPort} %h %p'`, - ) + if (httpProxyPort && whichSync('socat')) { + // macOS with socat available: same HTTP CONNECT spelling as Linux. + // This is the only stock ProxyCommand tool that can authenticate to + // the proxy — BSD nc (below) cannot, so socat is what makes + // git-over-ssh work when proxyAuthToken is set. socat is not a + // required macOS dependency; detected per call, nc fallback below. + const socatAuth = proxyAuthToken + ? `,proxyauth=${userRaw}:${proxyAuthToken}` + : '' + envVars.push( + `GIT_SSH_COMMAND=ssh ${sshMuxOverride} -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=${httpProxyPort}${socatAuth}'`, + ) + } else { + // Fallback: BSD nc SOCKS5 proxy support (-X 5 -x). nc has no SOCKS5 + // auth, so when proxyAuthToken is set, git-over-ssh fails at the + // SOCKS handshake — install socat, or use git-over-https + // (HTTP_PROXY carries the credential). + envVars.push( + `GIT_SSH_COMMAND=ssh ${sshMuxOverride} -o ProxyCommand='nc -X 5 -x localhost:${socksProxyPort} %h %p'`, + ) + } } else if (platform === 'linux' && httpProxyPort) { // Linux: use socat HTTP CONNECT via the HTTP proxy bridge. // socat is already a required Linux sandbox dependency, and PROXY: is diff --git a/test/sandbox/connect-non-tls.test.ts b/test/sandbox/connect-non-tls.test.ts index 34c47f65d..d3ef237e9 100644 --- a/test/sandbox/connect-non-tls.test.ts +++ b/test/sandbox/connect-non-tls.test.ts @@ -7,7 +7,7 @@ import { looksLikeClientHello } from '../../src/sandbox/tls-terminate-proxy.js' /** * Regression: with tlsTerminate on, every CONNECT was handed to the - * TLS terminator regardless of port/protocol. The Linux sandbox routes + * TLS terminator regardless of port/protocol. The sandbox routes * `git push` over SSH through this proxy via * GIT_SSH_COMMAND=ssh -o ProxyCommand='socat - PROXY:localhost:%h:%p,...' * which is `CONNECT github.com:22` — not TLS. The terminator replied with a diff --git a/test/sandbox/proxy-env-vars.test.ts b/test/sandbox/proxy-env-vars.test.ts index 5c99c65a8..f83fe064e 100644 --- a/test/sandbox/proxy-env-vars.test.ts +++ b/test/sandbox/proxy-env-vars.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'bun:test' +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test' import { createServer } from 'node:http' import type { Server } from 'node:http' import type { AddressInfo } from 'node:net' @@ -6,6 +6,8 @@ import { generateProxyEnvVars, CA_TRUST_VARS, } from '../../src/sandbox/sandbox-utils.js' +import * as which from '../../src/utils/which.js' +import * as platform from '../../src/utils/platform.js' import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import type { SandboxRuntimeConfig } from '../../src/sandbox/sandbox-config.js' import { spawnAsync } from '../helpers/spawn.js' @@ -98,6 +100,72 @@ describe('generateProxyEnvVars', () => { }) }) + describe('GIT_SSH_COMMAND on macOS', () => { + // The proxy requires per-session auth when SRT owns it, and BSD nc has + // no SOCKS5 auth — so with socat available, macOS must emit the same + // authenticated HTTP CONNECT spelling as Linux, or git-over-ssh dies at + // the SOCKS handshake. The nc spelling is only a fallback for hosts + // without socat. + const MUX = '-o ControlMaster=no -o ControlPath=none' + let whichSpy: ReturnType + let platformSpy: ReturnType + + beforeEach(() => { + whichSpy = spyOn(which, 'whichSync') + platformSpy = spyOn(platform, 'getPlatform') + platformSpy.mockReturnValue('macos') + }) + + afterEach(() => { + whichSpy.mockRestore() + platformSpy.mockRestore() + }) + + const socatPresent = (bin: string) => + bin === 'socat' ? '/opt/homebrew/bin/socat' : `/usr/bin/${bin}` + const socatAbsent = (bin: string) => + bin === 'socat' ? null : `/usr/bin/${bin}` + + it('uses socat with proxyauth when socat is on PATH and auth is set', () => { + whichSpy.mockImplementation(socatPresent) + const env = generateProxyEnvVars(3128, 1080, undefined, 'tok') + expect(env).toContain( + `GIT_SSH_COMMAND=ssh ${MUX} -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=3128,proxyauth=srt:tok'`, + ) + }) + + it('carries per-command attribution in the proxyauth username', () => { + whichSpy.mockImplementation(socatPresent) + const env = generateProxyEnvVars( + 3128, + 1080, + undefined, + 'tok', + undefined, + 'dGVzdA', + ) + expect(env).toContain( + `GIT_SSH_COMMAND=ssh ${MUX} -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=3128,proxyauth=srt.dGVzdA:tok'`, + ) + }) + + it('omits proxyauth when no auth token is configured', () => { + whichSpy.mockImplementation(socatPresent) + const env = generateProxyEnvVars(3128, 1080) + expect(env).toContain( + `GIT_SSH_COMMAND=ssh ${MUX} -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=3128'`, + ) + }) + + it('falls back to the nc spelling when socat is not installed', () => { + whichSpy.mockImplementation(socatAbsent) + const env = generateProxyEnvVars(3128, 1080, undefined, 'tok') + expect(env).toContain( + `GIT_SSH_COMMAND=ssh ${MUX} -o ProxyCommand='nc -X 5 -x localhost:1080 %h %p'`, + ) + }) + }) + describe('NO_PROXY', () => { it('does not exclude .local hostnames from the proxy', () => { // Under network restriction the child has no usable resolver, so a From 3f55b35a92e1531860b0316016d060dc2f65b5ab Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Thu, 6 Aug 2026 15:50:45 -0700 Subject: [PATCH 11/18] =?UTF-8?q?feat(filesystem):=20denyReadAlwaysExcept?= =?UTF-8?q?=20=E2=80=94=20carve=20known-safe=20names=20out=20of=20credenti?= =?UTF-8?q?al=20globs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit denyReadAlways globs are deliberately broad ("/**/.env*", "/**/id_*"), which also catches files that are meant to be read: .env.example and friends are templates with no secrets, and id_*.pub are public keys — whose unreadability breaks ssh's IdentitiesOnly agent flow (ssh needs the .pub to know which agent key to offer). Plain globs cannot express "everything except this suffix". New optional filesystem.denyReadAlwaysExcept: paths re-allowed for reading even when they match denyReadAlways. Highest-priority read rule: denyAlwaysExcept > denyAlways > allowWithinDeny > denyOnly. - macOS: exception rules are emitted immediately after the denyAlways denies; Seatbelt is last-match-wins, so the allow fires when both match. No move-blocking changes needed: creation/rename inside write-allowed dirs is already re-allowed per-path by the writeAllowPaths pass, and that is the only place writes happen. - Linux: the platform layer only sees concrete paths, so exceptions apply as set-subtraction in sandbox-manager (both getFsReadConfig and the per-exec path), comparing normalized paths. Also fixes two pre-existing gaps in the denyReadAlways feature: - The maskedFileBinds readConfig reconstruction on macOS dropped denyAlways, silently erasing every credential glob whenever credentials.files was configured. - hasReadRestrictions (macOS and Linux) ignored denyAlways, so a config whose only read restriction was denyReadAlways ran unsandboxed. Tests: new test/sandbox/deny-read-always.test.ts pins profile ordering, the only-denyAlways wrapping fix, the mask-path regression, sandbox-exec integration (.env denied / .env.example readable, including a file created in-sandbox), and Linux set-subtraction; config-validation covers the new schema key. --- src/sandbox/linux-sandbox-utils.ts | 7 +- src/sandbox/macos-sandbox-utils.ts | 32 ++++- src/sandbox/sandbox-config.ts | 13 +- src/sandbox/sandbox-manager.ts | 66 +++++++++- src/sandbox/sandbox-schemas.ts | 8 +- test/config-validation.test.ts | 19 +++ test/sandbox/deny-read-always.test.ts | 181 ++++++++++++++++++++++++++ 7 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 test/sandbox/deny-read-always.test.ts diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index c4e27d180..160a1947d 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -1743,10 +1743,13 @@ export async function wrapCommandWithSandboxLinux( } = params // Determine if we have restrictions to apply - // Read: denyOnly pattern - empty array means no restrictions + // Read: denyOnly pattern - empty array means no restrictions; denyAlways + // alone also counts (a config may carry only credential globs) // Write: allowOnly pattern - undefined means no restrictions, any config means restrictions const hasReadRestrictions = - (readConfig && readConfig.denyOnly.length > 0) || + (readConfig && + (readConfig.denyOnly.length > 0 || + (readConfig.denyAlways ?? []).length > 0)) || (maskedFileBinds !== undefined && maskedFileBinds.length > 0) const hasWriteRestrictions = writeConfig !== undefined const hasEnvRestrictions = diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 066334ab3..4c0aa0d4a 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -361,6 +361,29 @@ function generateReadRules( } } + // denyAlwaysExcept: re-allow pass that takes precedence over denyAlways. + // Emitted after the denyAlways loop so these allow rules win + // (last-match-wins). Intended for known-safe names caught by a broad + // credential glob — e.g. "/**/.env.example" alongside a "/**/.env*" deny. + for (const pathPattern of config.denyAlwaysExcept || []) { + const normalizedPath = normalizePathForSandbox(pathPattern) + + if (containsGlobChars(normalizedPath)) { + const regexPattern = globToRegex(normalizedPath) + rules.push( + `(allow file-read*`, + ` (regex ${escapePath(regexPattern)})`, + ` (with message "${logTag}"))`, + ) + } else { + rules.push( + `(allow file-read*`, + ` (subpath ${escapePath(normalizedPath)})`, + ` (with message "${logTag}"))`, + ) + } + } + // Allow stat/lstat on all directories so that realpath() can traverse // path components within denied regions. Without this, C realpath() fails // when resolving symlinks because it needs to lstat every intermediate @@ -875,13 +898,18 @@ export function wrapCommandWithSandboxMacOS( ...maskedFileBinds.map(b => b.realPath), ], allowWithinDeny: readConfigIn?.allowWithinDeny, + denyAlways: readConfigIn?.denyAlways, + denyAlwaysExcept: readConfigIn?.denyAlwaysExcept, } } // Determine if we have restrictions to apply - // Read: denyOnly pattern - empty array means no restrictions + // Read: denyOnly pattern - empty array means no restrictions; denyAlways + // alone also counts (a config may carry only credential globs) // Write: allowOnly pattern - undefined means no restrictions, any config means restrictions - const hasReadRestrictions = readConfig && readConfig.denyOnly.length > 0 + const hasReadRestrictions = + readConfig !== undefined && + (readConfig.denyOnly.length > 0 || (readConfig.denyAlways ?? []).length > 0) const hasWriteRestrictions = writeConfig !== undefined const hasEnvRestrictions = (unsetEnvVars !== undefined && unsetEnvVars.length > 0) || diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 249984c91..70975735c 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -876,7 +876,18 @@ export const FilesystemConfigSchema = z.object({ 'Paths denied for reading that take precedence over allowRead. ' + 'Use for credential-style patterns (e.g. "/**/.env*", "/**/credentials") ' + 'inside a broadly-allowed directory. Patterns with a leading "/" match ' + - 'globally; otherwise they are CWD-relative.', + 'globally; otherwise they are CWD-relative. Known-safe names caught by ' + + 'a broad pattern can be carved back out with denyReadAlwaysExcept.', + ), + denyReadAlwaysExcept: z + .array(filesystemPathSchema) + .optional() + .describe( + 'Paths re-allowed for reading even when they match denyReadAlways. ' + + 'Use for known-safe names caught by a broad credential glob — e.g. ' + + '"/**/.env.example" alongside a "/**/.env*" deny, or "/**/id_*.pub" ' + + 'alongside "/**/id_*". Highest-priority read rule; same glob syntax ' + + 'and leading-"/" anchoring as denyReadAlways.', ), allowWrite: z .array(filesystemPathSchema) diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index f6b8dcb14..69c52fc87 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -81,6 +81,7 @@ import { removeTrailingGlobSuffix, expandGlobPattern, decodeSandboxedCommand, + normalizePathForSandbox, } from './sandbox-utils.js' import { SandboxViolationStore, @@ -1141,11 +1142,55 @@ function getFsReadConfig(): FsReadRestrictionConfig { } } + // Process denyReadAlwaysExcept paths (re-allows that beat denyReadAlways). + // On Linux both sides are concrete paths after glob expansion, so + // exceptions apply as set-subtraction here; on macOS the patterns pass + // through and the profile emits them as allow rules after the denyAlways + // denies (Seatbelt last-match-wins). + const denyAlwaysExceptPaths: string[] = [] + for (const p of config.filesystem.denyReadAlwaysExcept ?? []) { + const stripped = removeTrailingGlobSuffix(p) + if (getPlatform() === 'linux' && containsGlobChars(stripped)) { + const expanded = expandGlobPattern(p) + logForDebugging( + `[Sandbox] Expanded denyReadAlwaysExcept glob pattern "${p}" to ${expanded.length} paths on Linux`, + ) + denyAlwaysExceptPaths.push(...expanded) + } else { + denyAlwaysExceptPaths.push(stripped) + } + } + return { denyOnly: denyPaths, allowWithinDeny: allowPaths, - denyAlways: denyAlwaysPaths, + denyAlways: subtractDenyAlwaysExceptOnLinux( + denyAlwaysPaths, + denyAlwaysExceptPaths, + ), + denyAlwaysExcept: denyAlwaysExceptPaths, + } +} + +/** + * On Linux the platform layer only ever sees concrete paths (globs are + * expanded upstream), so denyAlwaysExcept is applied by removing excepted + * paths from the denyAlways list. Paths are compared after + * normalizePathForSandbox so spellings like "~/x" and its expansion match. + * On other platforms the list passes through untouched — the rule + * generators emit the exceptions themselves. + */ +function subtractDenyAlwaysExceptOnLinux( + denyAlwaysPaths: string[], + denyAlwaysExceptPaths: string[], +): string[] { + if (getPlatform() !== 'linux' || denyAlwaysExceptPaths.length === 0) { + return denyAlwaysPaths } + const except = new Set( + denyAlwaysExceptPaths.map(p => normalizePathForSandbox(p)), + ) + return denyAlwaysPaths.filter(p => !except.has(normalizePathForSandbox(p))) } function getFsWriteConfig(): FsWriteRestrictionConfig { @@ -1558,10 +1603,27 @@ async function wrapWithSandbox( expandedDenyReadAlways.push(stripped) } } + const rawDenyReadAlwaysExcept = + customConfig?.filesystem?.denyReadAlwaysExcept ?? + config?.filesystem.denyReadAlwaysExcept ?? + [] + const expandedDenyReadAlwaysExcept: string[] = [] + for (const p of rawDenyReadAlwaysExcept) { + const stripped = removeTrailingGlobSuffix(p) + if (getPlatform() === 'linux' && containsGlobChars(stripped)) { + expandedDenyReadAlwaysExcept.push(...expandGlobPattern(p)) + } else { + expandedDenyReadAlwaysExcept.push(stripped) + } + } readConfig = { denyOnly: expandedDenyRead, allowWithinDeny: expandedAllowRead, - denyAlways: expandedDenyReadAlways, + denyAlways: subtractDenyAlwaysExceptOnLinux( + expandedDenyReadAlways, + expandedDenyReadAlwaysExcept, + ), + denyAlwaysExcept: expandedDenyReadAlwaysExcept, } } diff --git a/src/sandbox/sandbox-schemas.ts b/src/sandbox/sandbox-schemas.ts index 471cb3bb1..eeba82089 100644 --- a/src/sandbox/sandbox-schemas.ts +++ b/src/sandbox/sandbox-schemas.ts @@ -14,8 +14,13 @@ * also match allowWithinDeny. Intended for credential-style patterns * (e.g. "/**\/.env*") that should never be readable regardless of * broader allow-rules. + * - `{denyAlways: [...], denyAlwaysExcept: [...paths]}` = re-allow reads for + * these paths even when they match denyAlways. Intended for known-safe + * names caught by a broad credential glob (e.g. "/**\/.env.example" + * alongside a "/**\/.env*" deny). * - * Rule priority (high → low): denyAlways > allowWithinDeny > denyOnly > default-allow. + * Rule priority (high → low): + * denyAlwaysExcept > denyAlways > allowWithinDeny > denyOnly > default-allow. * * This is maximally permissive by default - only explicitly denied paths are blocked. */ @@ -23,6 +28,7 @@ export interface FsReadRestrictionConfig { denyOnly: string[] allowWithinDeny?: string[] denyAlways?: string[] + denyAlwaysExcept?: string[] } /** diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index 7f28676b0..4ac3a6d08 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -106,6 +106,25 @@ describe('Config Validation', () => { expect(result.success).toBe(true) }) + test('should validate config with denyReadAlwaysExcept', () => { + const config = { + network: { + allowedDomains: [], + deniedDomains: [], + }, + filesystem: { + denyRead: [], + denyReadAlways: ['/**/.env*', '/**/id_*'], + denyReadAlwaysExcept: ['/**/.env.example', '/**/id_*.pub'], + allowWrite: [], + denyWrite: [], + }, + } + + const result = SandboxRuntimeConfigSchema.safeParse(config) + expect(result.success).toBe(true) + }) + test('should validate config with optional fields', () => { const config = { network: { diff --git a/test/sandbox/deny-read-always.test.ts b/test/sandbox/deny-read-always.test.ts new file mode 100644 index 000000000..0082310ac --- /dev/null +++ b/test/sandbox/deny-read-always.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' +import type { + FsReadRestrictionConfig, + FsWriteRestrictionConfig, +} from '../../src/sandbox/sandbox-schemas.js' +import { isLinux, isMacOS } from '../helpers/platform.js' + +/** + * Tests for denyReadAlways and its exception layer denyReadAlwaysExcept. + * + * denyReadAlways is a final-deny pass that beats allowRead (allowWithinDeny); + * denyReadAlwaysExcept re-allows specific known-safe names that a broad + * credential glob catches (e.g. "/**\/.env.example" alongside "/**\/.env*"). + * Priority: denyAlwaysExcept > denyAlways > allowWithinDeny > denyOnly. + */ +describe.if(isMacOS)('macOS denyReadAlways / denyReadAlwaysExcept', () => { + const TEST_BASE_DIR = join(tmpdir(), 'deny-always-test-' + Date.now()) + const TEST_SECRET_FILE = join(TEST_BASE_DIR, '.env') + const TEST_TEMPLATE_FILE = join(TEST_BASE_DIR, '.env.example') + const TEST_SECRET_CONTENT = 'SECRET=real_value' + const TEST_TEMPLATE_CONTENT = 'SECRET=fill_me_in' + + beforeAll(() => { + mkdirSync(TEST_BASE_DIR, { recursive: true }) + writeFileSync(TEST_SECRET_FILE, TEST_SECRET_CONTENT) + writeFileSync(TEST_TEMPLATE_FILE, TEST_TEMPLATE_CONTENT) + }) + + afterAll(() => { + if (existsSync(TEST_BASE_DIR)) { + rmSync(TEST_BASE_DIR, { recursive: true, force: true }) + } + }) + + const readConfig: FsReadRestrictionConfig = { + denyOnly: [], + allowWithinDeny: [], + denyAlways: ['/**/.env*'], + denyAlwaysExcept: ['/**/.env.example'], + } + + it('emits the exception allow after the denyAlways deny (last-match-wins)', () => { + const wrapped = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig, + writeConfig: undefined, + }) + + // globToRegex('/**/.env*') → ^/(.*/)?\.env[^/]*$ + // globToRegex('/**/.env.example') → ^/(.*/)?\.env\.example$ + const denyAt = wrapped.indexOf('.env[^/]*') + const exceptAt = wrapped.indexOf('.env\\\\.example') + expect(denyAt).toBeGreaterThan(-1) + expect(exceptAt).toBeGreaterThan(-1) + expect(exceptAt).toBeGreaterThan(denyAt) + }) + + it('wraps the command when only denyAlways is configured (empty denyOnly)', () => { + const wrapped = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: [], denyAlways: ['/**/.env*'] }, + writeConfig: undefined, + }) + + expect(wrapped).not.toBe('true') + expect(wrapped).toContain('sandbox-exec') + }) + + it('keeps denyAlways rules when file masking folds paths into denyOnly', () => { + // Regression: the maskedFileBinds readConfig reconstruction used to drop + // denyAlways (and would have dropped denyAlwaysExcept), silently erasing + // every credential glob whenever credentials.files was configured. + const wrapped = wrapCommandWithSandboxMacOS({ + command: 'true', + needsNetworkRestriction: false, + readConfig, + writeConfig: undefined, + maskedFileBinds: [ + { realPath: join(TEST_BASE_DIR, 'cred'), fakePath: '/dev/null' }, + ], + }) + + expect(wrapped).toContain('.env[^/]*') + expect(wrapped).toContain('.env\\\\.example') + }) + + it('denies the credential file but allows the excepted template', () => { + const wrapped = wrapCommandWithSandboxMacOS({ + command: `cat ${TEST_SECRET_FILE}; cat ${TEST_TEMPLATE_FILE}`, + needsNetworkRestriction: false, + readConfig, + writeConfig: undefined, + }) + + const result = spawnSync(wrapped, { shell: true, encoding: 'utf8' }) + + expect(result.stdout).not.toContain(TEST_SECRET_CONTENT) + expect(result.stdout).toContain(TEST_TEMPLATE_CONTENT) + }) + + it('supports creating an excepted file in a write-allowed dir and reading it back', () => { + // Creation inside a write-allowed dir is governed by the writeAllowPaths + // re-allow (upstream behavior, applies to any name); what denyAlwaysExcept + // adds is that the excepted file is *readable* afterwards, while a + // denied name written the same way stays unreadable. + const writeConfig: FsWriteRestrictionConfig = { + allowOnly: [TEST_BASE_DIR], + denyWithinAllow: [], + } + const subDir = join(TEST_BASE_DIR, 'sub') + const newTemplate = join(subDir, '.env.example') + const newSecret = join(subDir, '.env') + + const wrapped = wrapCommandWithSandboxMacOS({ + command: `mkdir -p ${subDir}; echo NEW_TEMPLATE > ${newTemplate} && cat ${newTemplate}; echo NEW_SECRET > ${newSecret}; cat ${newSecret} || echo SECRET_READBACK_BLOCKED`, + needsNetworkRestriction: false, + readConfig, + writeConfig, + }) + + const result = spawnSync(wrapped, { shell: true, encoding: 'utf8' }) + + expect(result.stdout).toContain('NEW_TEMPLATE') + expect(result.stdout).not.toContain('NEW_SECRET') + expect(result.stdout).toContain('SECRET_READBACK_BLOCKED') + }) +}) + +describe.if(isLinux)('Linux denyReadAlwaysExcept set-subtraction', () => { + const RAW_BASE_DIR = join(tmpdir(), 'deny-always-linux-' + Date.now()) + + beforeAll(() => { + mkdirSync(RAW_BASE_DIR, { recursive: true }) + writeFileSync(join(RAW_BASE_DIR, 'secret.env'), 'SECRET=value') + writeFileSync(join(RAW_BASE_DIR, 'example.env'), 'SECRET=fill_me_in') + }) + + afterAll(() => { + if (existsSync(RAW_BASE_DIR)) { + rmSync(RAW_BASE_DIR, { recursive: true, force: true }) + } + }) + + it('removes excepted concrete paths from the expanded denyAlways list', async () => { + const { SandboxManager } = await import( + '../../src/sandbox/sandbox-manager.js' + ) + + await SandboxManager.reset() + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [], + allowWrite: ['/tmp'], + denyWrite: [], + denyReadAlways: [join(RAW_BASE_DIR, '*.env')], + denyReadAlwaysExcept: [join(RAW_BASE_DIR, 'example.env')], + }, + }) + + try { + const readConfig = SandboxManager.getFsReadConfig() + const denyAlways = readConfig.denyAlways ?? [] + expect(denyAlways.some((p: string) => p.endsWith('secret.env'))).toBe( + true, + ) + expect(denyAlways.some((p: string) => p.endsWith('example.env'))).toBe( + false, + ) + } finally { + await SandboxManager.reset() + } + }) +}) From 6d5070208edf0dba7f854021160cde4ef0a0e286 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Thu, 6 Aug 2026 15:51:59 -0700 Subject: [PATCH 12/18] chore(ltic): version 0.0.70-ltic.5; pin denyReadAlwaysExcept in unrecognized-keys test --- package-lock.json | 4 ++-- package.json | 2 +- test/config-unrecognized-keys.test.ts | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d31f00f0e..66a78a8c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.4", + "version": "0.0.70-ltic.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.4", + "version": "0.0.70-ltic.5", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", diff --git a/package.json b/package.json index a297a60a9..96e3f8468 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/sandbox-runtime", - "version": "0.0.70-ltic.4", + "version": "0.0.70-ltic.5", "description": "Anthropic Sandbox Runtime (ASRT) - A general-purpose tool for wrapping security boundaries around arbitrary processes", "type": "module", "main": "./dist/index.js", diff --git a/test/config-unrecognized-keys.test.ts b/test/config-unrecognized-keys.test.ts index be692d42e..300ebd3c6 100644 --- a/test/config-unrecognized-keys.test.ts +++ b/test/config-unrecognized-keys.test.ts @@ -27,8 +27,9 @@ describe('collectUnrecognizedConfigKeys', () => { filesystem: { denyRead: ['/Users'], allowRead: ['~/src'], - // Fork key (upstream PR #284) + // Fork keys (upstream PR #284) denyReadAlways: ['/**/.env*', '/**/credentials'], + denyReadAlwaysExcept: ['/**/.env.example', '/**/id_*.pub'], allowWrite: ['/tmp'], denyWrite: [], }, From 958364282f51a8f2a28e43325624f6adac178937 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Thu, 24 Sep 2026 16:31:52 -0700 Subject: [PATCH 13/18] fix(network): allowAllDomains no longer admits IP literals or loopback names Upstream's resolved-address guard treats an allow-listed IP literal or localhost name as an explicit choice and skips its checks. The flag's short-circuit made every such destination count as allow-listed, so 169.254.169.254, 127.0.0.1 and localhost were reachable. The flag now covers hostnames only; those destinations still need an allowedDomains entry. --- src/sandbox/sandbox-manager.ts | 13 +++++++++++-- test/sandbox/update-config.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 2644008ff..3b8a59c54 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -108,6 +108,8 @@ import { stripDomainPatternPort, } from './domain-pattern.js' import type { ChildProcess } from 'node:child_process' +import { isIP } from 'node:net' +import { isLoopbackName } from './address.js' import type { DirectLookup, ResolvedParentProxy } from './parent-proxy.js' import { createResolvedAddressGuard, @@ -378,8 +380,15 @@ async function filterNetworkRequest( } // allowAllDomains short-circuits the allowlist after denies are checked, - // so explicit deniedDomains entries still take effect. - if (config.network.allowAllDomains) { + // so explicit deniedDomains entries still take effect. It covers hostnames + // only: the resolved-address guard trusts an allow-listed IP literal or + // loopback name as an explicit choice, so those must still earn an + // allowedDomains entry rather than ride the flag to IMDS or host loopback. + if ( + config.network.allowAllDomains && + !isIP(canonicalHost) && + !isLoopbackName(canonicalHost) + ) { logForDebugging(`Allowed by allowAllDomains: ${host}:${port}`) return true } diff --git a/test/sandbox/update-config.test.ts b/test/sandbox/update-config.test.ts index f10dfa1fc..6125acd21 100644 --- a/test/sandbox/update-config.test.ts +++ b/test/sandbox/update-config.test.ts @@ -385,6 +385,33 @@ describe('proxy auth + network deny semantics', () => { expect((await proxyRequest(port, 'nope.net')).statusCode).toBe(403) expect(asked).toBe(false) }) + + it('allowAllDomains admits hostnames but not IP literals or loopback names', async () => { + await SandboxManager.initialize({ + network: { + allowedDomains: [], + deniedDomains: [], + allowAllDomains: true, + }, + filesystem: { denyRead: [], allowWrite: [], denyWrite: [] }, + }) + const port = SandboxManager.getProxyPort()! + expect((await proxyRequest(port, 'example.com')).allowed).toBe(true) + for (const host of [ + '169.254.169.254', + '127.0.0.1', + '127.1', + '[::1]', + '[::ffff:127.0.0.1]', + 'localhost', + 'foo.localhost', + ]) { + expect({ host, ...(await proxyRequest(port, host)) }).toMatchObject({ + host, + statusCode: 403, + }) + } + }) }) describe('SandboxManager.updateConfig', () => { From c711f6153351da411165450b97d41b49b32fc9a2 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Thu, 24 Sep 2026 16:37:28 -0700 Subject: [PATCH 14/18] =?UTF-8?q?chore(ltic):=20retire=20denyReadAlways=20?= =?UTF-8?q?=E2=80=94=20superseded=20by=20upstream=20deny-within-allow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream v0.0.77 makes a denyRead glob beat an enclosing allowRead on macOS (d106511) and Linux, and a narrower allowRead carves exceptions back out of a covering deny glob. That covers what denyReadAlways and denyReadAlwaysExcept were added for. The merge already took upstream's rewritten rule generators; this drops the leftover schema fields and tests. Leftover keys in a config are now reported as unrecognized. --- src/sandbox/sandbox-config.ts | 20 --- test/config-unrecognized-keys.test.ts | 30 ++++- test/config-validation.test.ts | 38 ------ test/sandbox/deny-read-always.test.ts | 181 -------------------------- 4 files changed, 23 insertions(+), 246 deletions(-) delete mode 100644 test/sandbox/deny-read-always.test.ts diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 27815676d..3fb24d409 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -922,26 +922,6 @@ export const FilesystemConfigSchema = z.object({ 'Paths to re-allow reading within denied regions (takes precedence over denyRead). ' + 'Use with denyRead to deny a broad region then allow back specific subdirectories.', ), - denyReadAlways: z - .array(filesystemPathSchema) - .optional() - .describe( - 'Paths denied for reading that take precedence over allowRead. ' + - 'Use for credential-style patterns (e.g. "/**/.env*", "/**/credentials") ' + - 'inside a broadly-allowed directory. Patterns with a leading "/" match ' + - 'globally; otherwise they are CWD-relative. Known-safe names caught by ' + - 'a broad pattern can be carved back out with denyReadAlwaysExcept.', - ), - denyReadAlwaysExcept: z - .array(filesystemPathSchema) - .optional() - .describe( - 'Paths re-allowed for reading even when they match denyReadAlways. ' + - 'Use for known-safe names caught by a broad credential glob — e.g. ' + - '"/**/.env.example" alongside a "/**/.env*" deny, or "/**/id_*.pub" ' + - 'alongside "/**/id_*". Highest-priority read rule; same glob syntax ' + - 'and leading-"/" anchoring as denyReadAlways.', - ), allowWrite: z .array(filesystemPathSchema) .describe('Paths allowed for writing'), diff --git a/test/config-unrecognized-keys.test.ts b/test/config-unrecognized-keys.test.ts index 300ebd3c6..ae498931b 100644 --- a/test/config-unrecognized-keys.test.ts +++ b/test/config-unrecognized-keys.test.ts @@ -26,10 +26,7 @@ describe('collectUnrecognizedConfigKeys', () => { }, filesystem: { denyRead: ['/Users'], - allowRead: ['~/src'], - // Fork keys (upstream PR #284) - denyReadAlways: ['/**/.env*', '/**/credentials'], - denyReadAlwaysExcept: ['/**/.env.example', '/**/id_*.pub'], + allowRead: ['~/src', '/**/.env.example'], allowWrite: ['/tmp'], denyWrite: [], }, @@ -53,14 +50,33 @@ describe('collectUnrecognizedConfigKeys', () => { ]) }) - test('flags a mistyped fork key in filesystem', () => { + test('flags a mistyped key in filesystem', () => { const config = { ...base, - filesystem: { ...base.filesystem, denyReadAlway: ['/**/.env*'] }, + filesystem: { ...base.filesystem, alowRead: ['~/src'] }, } expect(collectUnrecognizedConfigKeys(config)).toEqual([ - 'filesystem.denyReadAlway', + 'filesystem.alowRead', + ]) + }) + + test('flags the retired denyReadAlways keys', () => { + // Retired in 0.0.77-ltic.1: upstream's denyRead now beats an enclosing + // allowRead, and a narrower allowRead carves exceptions back out. A + // leftover config must be told its keys no longer do anything. + const config = { + ...base, + filesystem: { + ...base.filesystem, + denyReadAlways: ['/**/.env*'], + denyReadAlwaysExcept: ['/**/.env.example'], + }, + } + + expect(collectUnrecognizedConfigKeys(config)).toEqual([ + 'filesystem.denyReadAlways', + 'filesystem.denyReadAlwaysExcept', ]) }) diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts index b6184a001..187cc1ee2 100644 --- a/test/config-validation.test.ts +++ b/test/config-validation.test.ts @@ -107,44 +107,6 @@ describe('Config Validation', () => { expect(result.success).toBe(true) }) - test('should validate config with denyReadAlways', () => { - const config = { - network: { - allowedDomains: [], - deniedDomains: [], - }, - filesystem: { - denyRead: ['/Users'], - allowRead: ['~/src'], - denyReadAlways: ['/**/.env*', '/**/credentials'], - allowWrite: [], - denyWrite: [], - }, - } - - const result = SandboxRuntimeConfigSchema.safeParse(config) - expect(result.success).toBe(true) - }) - - test('should validate config with denyReadAlwaysExcept', () => { - const config = { - network: { - allowedDomains: [], - deniedDomains: [], - }, - filesystem: { - denyRead: [], - denyReadAlways: ['/**/.env*', '/**/id_*'], - denyReadAlwaysExcept: ['/**/.env.example', '/**/id_*.pub'], - allowWrite: [], - denyWrite: [], - }, - } - - const result = SandboxRuntimeConfigSchema.safeParse(config) - expect(result.success).toBe(true) - }) - test('should validate config with optional fields', () => { const config = { network: { diff --git a/test/sandbox/deny-read-always.test.ts b/test/sandbox/deny-read-always.test.ts deleted file mode 100644 index 0082310ac..000000000 --- a/test/sandbox/deny-read-always.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from 'bun:test' -import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { wrapCommandWithSandboxMacOS } from '../../src/sandbox/macos-sandbox-utils.js' -import type { - FsReadRestrictionConfig, - FsWriteRestrictionConfig, -} from '../../src/sandbox/sandbox-schemas.js' -import { isLinux, isMacOS } from '../helpers/platform.js' - -/** - * Tests for denyReadAlways and its exception layer denyReadAlwaysExcept. - * - * denyReadAlways is a final-deny pass that beats allowRead (allowWithinDeny); - * denyReadAlwaysExcept re-allows specific known-safe names that a broad - * credential glob catches (e.g. "/**\/.env.example" alongside "/**\/.env*"). - * Priority: denyAlwaysExcept > denyAlways > allowWithinDeny > denyOnly. - */ -describe.if(isMacOS)('macOS denyReadAlways / denyReadAlwaysExcept', () => { - const TEST_BASE_DIR = join(tmpdir(), 'deny-always-test-' + Date.now()) - const TEST_SECRET_FILE = join(TEST_BASE_DIR, '.env') - const TEST_TEMPLATE_FILE = join(TEST_BASE_DIR, '.env.example') - const TEST_SECRET_CONTENT = 'SECRET=real_value' - const TEST_TEMPLATE_CONTENT = 'SECRET=fill_me_in' - - beforeAll(() => { - mkdirSync(TEST_BASE_DIR, { recursive: true }) - writeFileSync(TEST_SECRET_FILE, TEST_SECRET_CONTENT) - writeFileSync(TEST_TEMPLATE_FILE, TEST_TEMPLATE_CONTENT) - }) - - afterAll(() => { - if (existsSync(TEST_BASE_DIR)) { - rmSync(TEST_BASE_DIR, { recursive: true, force: true }) - } - }) - - const readConfig: FsReadRestrictionConfig = { - denyOnly: [], - allowWithinDeny: [], - denyAlways: ['/**/.env*'], - denyAlwaysExcept: ['/**/.env.example'], - } - - it('emits the exception allow after the denyAlways deny (last-match-wins)', () => { - const wrapped = wrapCommandWithSandboxMacOS({ - command: 'true', - needsNetworkRestriction: false, - readConfig, - writeConfig: undefined, - }) - - // globToRegex('/**/.env*') → ^/(.*/)?\.env[^/]*$ - // globToRegex('/**/.env.example') → ^/(.*/)?\.env\.example$ - const denyAt = wrapped.indexOf('.env[^/]*') - const exceptAt = wrapped.indexOf('.env\\\\.example') - expect(denyAt).toBeGreaterThan(-1) - expect(exceptAt).toBeGreaterThan(-1) - expect(exceptAt).toBeGreaterThan(denyAt) - }) - - it('wraps the command when only denyAlways is configured (empty denyOnly)', () => { - const wrapped = wrapCommandWithSandboxMacOS({ - command: 'true', - needsNetworkRestriction: false, - readConfig: { denyOnly: [], denyAlways: ['/**/.env*'] }, - writeConfig: undefined, - }) - - expect(wrapped).not.toBe('true') - expect(wrapped).toContain('sandbox-exec') - }) - - it('keeps denyAlways rules when file masking folds paths into denyOnly', () => { - // Regression: the maskedFileBinds readConfig reconstruction used to drop - // denyAlways (and would have dropped denyAlwaysExcept), silently erasing - // every credential glob whenever credentials.files was configured. - const wrapped = wrapCommandWithSandboxMacOS({ - command: 'true', - needsNetworkRestriction: false, - readConfig, - writeConfig: undefined, - maskedFileBinds: [ - { realPath: join(TEST_BASE_DIR, 'cred'), fakePath: '/dev/null' }, - ], - }) - - expect(wrapped).toContain('.env[^/]*') - expect(wrapped).toContain('.env\\\\.example') - }) - - it('denies the credential file but allows the excepted template', () => { - const wrapped = wrapCommandWithSandboxMacOS({ - command: `cat ${TEST_SECRET_FILE}; cat ${TEST_TEMPLATE_FILE}`, - needsNetworkRestriction: false, - readConfig, - writeConfig: undefined, - }) - - const result = spawnSync(wrapped, { shell: true, encoding: 'utf8' }) - - expect(result.stdout).not.toContain(TEST_SECRET_CONTENT) - expect(result.stdout).toContain(TEST_TEMPLATE_CONTENT) - }) - - it('supports creating an excepted file in a write-allowed dir and reading it back', () => { - // Creation inside a write-allowed dir is governed by the writeAllowPaths - // re-allow (upstream behavior, applies to any name); what denyAlwaysExcept - // adds is that the excepted file is *readable* afterwards, while a - // denied name written the same way stays unreadable. - const writeConfig: FsWriteRestrictionConfig = { - allowOnly: [TEST_BASE_DIR], - denyWithinAllow: [], - } - const subDir = join(TEST_BASE_DIR, 'sub') - const newTemplate = join(subDir, '.env.example') - const newSecret = join(subDir, '.env') - - const wrapped = wrapCommandWithSandboxMacOS({ - command: `mkdir -p ${subDir}; echo NEW_TEMPLATE > ${newTemplate} && cat ${newTemplate}; echo NEW_SECRET > ${newSecret}; cat ${newSecret} || echo SECRET_READBACK_BLOCKED`, - needsNetworkRestriction: false, - readConfig, - writeConfig, - }) - - const result = spawnSync(wrapped, { shell: true, encoding: 'utf8' }) - - expect(result.stdout).toContain('NEW_TEMPLATE') - expect(result.stdout).not.toContain('NEW_SECRET') - expect(result.stdout).toContain('SECRET_READBACK_BLOCKED') - }) -}) - -describe.if(isLinux)('Linux denyReadAlwaysExcept set-subtraction', () => { - const RAW_BASE_DIR = join(tmpdir(), 'deny-always-linux-' + Date.now()) - - beforeAll(() => { - mkdirSync(RAW_BASE_DIR, { recursive: true }) - writeFileSync(join(RAW_BASE_DIR, 'secret.env'), 'SECRET=value') - writeFileSync(join(RAW_BASE_DIR, 'example.env'), 'SECRET=fill_me_in') - }) - - afterAll(() => { - if (existsSync(RAW_BASE_DIR)) { - rmSync(RAW_BASE_DIR, { recursive: true, force: true }) - } - }) - - it('removes excepted concrete paths from the expanded denyAlways list', async () => { - const { SandboxManager } = await import( - '../../src/sandbox/sandbox-manager.js' - ) - - await SandboxManager.reset() - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { - denyRead: [], - allowWrite: ['/tmp'], - denyWrite: [], - denyReadAlways: [join(RAW_BASE_DIR, '*.env')], - denyReadAlwaysExcept: [join(RAW_BASE_DIR, 'example.env')], - }, - }) - - try { - const readConfig = SandboxManager.getFsReadConfig() - const denyAlways = readConfig.denyAlways ?? [] - expect(denyAlways.some((p: string) => p.endsWith('secret.env'))).toBe( - true, - ) - expect(denyAlways.some((p: string) => p.endsWith('example.env'))).toBe( - false, - ) - } finally { - await SandboxManager.reset() - } - }) -}) From 9da617ed2659b00d968f458813cb25af202ccb00 Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Thu, 24 Sep 2026 16:40:07 -0700 Subject: [PATCH 15/18] =?UTF-8?q?docs(ltic):=20v0.0.70=20=E2=86=92=20v0.0.?= =?UTF-8?q?77=20sync=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 0.70_to_0.77_NEW_FEATURES.md | 150 +++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 0.70_to_0.77_NEW_FEATURES.md diff --git a/0.70_to_0.77_NEW_FEATURES.md b/0.70_to_0.77_NEW_FEATURES.md new file mode 100644 index 000000000..2686c0e34 --- /dev/null +++ b/0.70_to_0.77_NEW_FEATURES.md @@ -0,0 +1,150 @@ +# sandbox-runtime v0.0.70 → v0.0.77: What's New and What It Means for the ccx Configs + +Status: `ltic-main` is merged up to upstream **v0.0.77** (ddbeb74, 76 commits), +version `0.0.77-ltic.1` (check with `srt --version`). Typecheck is clean and +`npm test` passes: 1218 pass, 689 skipped (Linux/Windows only), 0 fail. + +This sync changed the fork, unlike the zero-conflict v0.0.70 sync: + +| Fork patch | Outcome | +|---|---| +| `network.allowAllDomains` | **Kept, and tightened** (see below). | +| `filesystem.denyReadAlways` / `denyReadAlwaysExcept` | **Retired.** Upstream now does this with plain `denyRead` / `allowRead`. | +| macOS socat `GIT_SSH_COMMAND` | Kept. Merged cleanly. | +| Violations file (`SRT_VIOLATIONS_FILE`) in `cli.ts` | Kept. Re-seated in the rewritten `main()`. | +| Unrecognized-settings-key warning | Kept. Moved into the rewritten `loadConfig`. | +| Version read from `package.json` | Dropped. Upstream has its own `getPackageVersion()`. | + +**Config changes were required, and have been made.** +- `~/.srt-claude-denyall.json` and `~/.srt-claude-allowall.json` are migrated. +- Originals are saved next to them as `*.pre-v0.0.77`. + +--- + +## 1. `denyReadAlways` is gone: use `denyRead` + `allowRead` + +Upstream d106511 (macOS) and the Linux rework (bf3959a, 879f3e0, …) make a +**`denyRead` glob beat an enclosing `allowRead` region**. For example, +`/**/.env*` stays denied inside `allowRead: ["."]` or `["~/src"]`. That's the +case denyReadAlways was built for. Upstream also refers to "a denyReadAlways +option that never landed" and has settled on this design instead. + +Exceptions work by listing the file in `allowRead`. A narrower `allowRead` +(an exact path, or a file-shaped glob) **carves back out** of a covering deny +glob. + +**Gotcha, verified empirically.** The carve-out only counts if the deny glob +covers a sample path built from the allow glob. `/**/id_*.pub` does **not** +carve out of `/**/id_ed25519*`, so `.pub` keys stayed denied. Spell the +allow in the same shape as the deny: + +```json +"denyRead": ["/**/id_rsa*", "/**/id_ed25519*", "/**/.env*", ...], +"allowRead": ["/**/id_rsa*.pub", "/**/id_ed25519*.pub", "/**/.env.example", ...] +``` + +Both ccx configs now use this. Results from a sandboxed `cat` under each config: + +| Path | Result | +|---|---| +| `./.env`, `sub/.env`, `.env.local`, `key.pem`, `credentials`, `sub/id_ed25519`, `~/.ssh/id_ed25519-github`, `~/Documents` | denied | +| `.env.example`, `.env.sample`, `id_ed25519*.pub`, `~/.ssh/config`, ordinary files | readable | + +What was lost: denyReadAlways also beat an *exact* `allowRead` of the same +file. Upstream deliberately lets the exact allow win. That only matters if an +untrusted layer can add `allowRead` entries, which the ccx setup doesn't have. + +Leftover `denyReadAlways` keys in any config now trigger the fork's +unrecognized-key warning. They no longer do anything. + +## 2. `allowAllDomains` no longer opens IMDS or host loopback + +New upstream **resolved-address guard** (a3ac53e and ~20 follow-ups): an +allowed *hostname* that resolves to one of these is refused: +- loopback, link-local, or a cloud-metadata address (including ones outside + link-local such as `fd00:ec2::254`); +- the host's own addresses; +- anything listed in `deniedResolvedAddresses`. + +NAT64, 6to4 and IPv4-mapped forms are decoded before the check. + +The guard treats an **IP literal or `localhost` name** on the allowlist as a +deliberate choice and skips it. The fork's flag made *everything* count as +allow-listed, so under the old build `curl --noproxy '' http://169.254.169.254/` +and `http://127.0.0.1:` were forwarded by the proxy. That hole already +existed in 0.0.70. + +Fork fix 9583642: the flag now covers **hostnames only**. IP literals, +`localhost` and `*.localhost` still need an explicit `allowedDomains` entry. + +| Target | old build | new build | +|---|---|---| +| `http://169.254.169.254/` via proxy | forwarded (timeout) | **403** | +| `http://127.0.0.1:9/`, `http://localhost:9/` | forwarded (502) | **403** | +| `https://example.com/` | 200 | 200 | + +Note that curl's default `no_proxy` sends `169.254/16` and loopback direct. +Seatbelt blocks those either way. + +If a sandboxed tool ever needs a local service, allow-list it explicitly, e.g. +`"localhost:5432"`. + +## 3. Breaking or stricter upstream behaviour to know about + +- **A settings file that is invalid, empty or unreadable now fails closed** + (e307e18). srt exits 1 instead of silently running with the defaults. +- **`--control-fd`** must be an integer ≥ 3 and readable, or srt refuses to + run. srt exits with the command (7c3eb33), and the child no longer + inherits the fd. +- **Deny globs ending in `/` are rejected** (322c59a), e.g. `"/data/*/"`. +- **IPv6 literals in domain lists must be bracketed** (a52b7d5): `[::1]:443`. +- **Default write paths now obey a read deny** (316e9f3). For example, + `~/.npm/_logs` stops being writable if a `denyRead` covers it; list it in + `allowWrite` to keep it. +- **A `/` read root (macOS) no longer switches off the denies beneath it** + (46c06d8). + +## 4. New features + +- **`network.deniedResolvedAddresses`**: extra CIDRs that allowed names + must not resolve to. Useful for the UBC intranet ranges (e.g. `10.0.0.0/8`, + `172.16.0.0/12`, `192.168.0.0/16`) if the sandbox shouldn't reach internal + hosts through public DNS names. The built-in set does *not* include RFC1918. +- **JVM proxy agent** (`javaAgentJarPath`, 9ee66e8): injected via + `JAVA_TOOL_OPTIONS`, so Gradle, Maven and Bazel honour the proxy and its + auth token. +- **Legible SSH refusals** (44f601b): an SSH client going through the + no-auth SOCKS path gets an in-band banner saying why. This doesn't apply to + the fork's macOS socat path, where a denied host shows up as a CONNECT + error plus a line in the violations file. +- **Violation lines are sanitized** at ingestion (1bcd1cc, a73fcff). Control + characters, bidi characters and `<`/`>` are stripped, so each violation is + exactly one physical line. This hardens the ccx PostToolUse hook against + forged lines for free. The hook's line format is unchanged. +- **Much smaller seatbelt profiles** (ca5501a, ee6b2eb: ~6–10× smaller), and + literal deny paths are emitted as literals (416eecc). +- TLS-terminate keeps the upstream connection alive per client (6fa7313). +- The package metadata moved to github.com/anthropics/sandbox-runtime. + +## 5. Things noticed during verification (pre-existing, not regressions) + +Both of these behave identically on the pre-sync build. + +- **`mktemp` fails in `$TMPDIR`.** On macOS `TMPDIR` is under + `/private/var/folders`, which the denyall config read-denies and never + allows writing. Tools that honour `TMPDIR` fail with EPERM, while + `/private/tmp` works. Options: + - add the per-user `$TMPDIR` to `allowRead` + `allowWrite`; + - have `_ccx_run` export `TMPDIR=/private/tmp/…` into the sandbox. +- **`git` over SSH doesn't authenticate from inside the sandbox.** socat + reaches github.com fine through the proxy. But the private key is + read-denied (`/**/id_ed25519*`), and ssh-agent's launchd socket is blocked + (`network-outbound …/Listeners` in the violations file). So auth fails. + If git-over-ssh is wanted, add the agent socket to + `network.allowUnixSockets`. Otherwise keep using HTTPS + `GH_TOKEN`. + +## 6. Still-open ideas from the v0.0.70 writeup + +The `GH_TOKEN` masking and `tlsTerminate` recommendations from +`0.62_to_0.70_NEW_FEATURES.md` still apply unchanged. Credential validation +under `allowAllDomains` was not touched upstream. From bc9448de9d3e0369c1df1dd2eac2061a0949ffaf Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Thu, 24 Sep 2026 16:51:00 -0700 Subject: [PATCH 16/18] =?UTF-8?q?docs(ltic):=20sync=20notes=20=E2=80=94=20?= =?UTF-8?q?the=20TMPDIR/SSH=20findings=20are=20bare-srt=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 0.70_to_0.77_NEW_FEATURES.md | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/0.70_to_0.77_NEW_FEATURES.md b/0.70_to_0.77_NEW_FEATURES.md index 2686c0e34..bcaf7962f 100644 --- a/0.70_to_0.77_NEW_FEATURES.md +++ b/0.70_to_0.77_NEW_FEATURES.md @@ -126,22 +126,20 @@ If a sandboxed tool ever needs a local service, allow-list it explicitly, e.g. - TLS-terminate keeps the upstream connection alive per client (6fa7313). - The package metadata moved to github.com/anthropics/sandbox-runtime. -## 5. Things noticed during verification (pre-existing, not regressions) - -Both of these behave identically on the pre-sync build. - -- **`mktemp` fails in `$TMPDIR`.** On macOS `TMPDIR` is under - `/private/var/folders`, which the denyall config read-denies and never - allows writing. Tools that honour `TMPDIR` fail with EPERM, while - `/private/tmp` works. Options: - - add the per-user `$TMPDIR` to `allowRead` + `allowWrite`; - - have `_ccx_run` export `TMPDIR=/private/tmp/…` into the sandbox. -- **`git` over SSH doesn't authenticate from inside the sandbox.** socat - reaches github.com fine through the proxy. But the private key is - read-denied (`/**/id_ed25519*`), and ssh-agent's launchd socket is blocked - (`network-outbound …/Listeners` in the violations file). So auth fails. - If git-over-ssh is wanted, add the agent socket to - `network.allowUnixSockets`. Otherwise keep using HTTPS + `GH_TOKEN`. +## 5. Things noticed when testing with bare `srt` (not issues under ccx) + +These showed up when testing with bare `srt --settings …`. They are +identical on the pre-sync build, and the ccx wrapper already handles both: + +- **`mktemp` fails in the default macOS `$TMPDIR`** (`/private/var/folders` + is read-denied). `_ccx_run` sets a per-PID `TMPDIR=/tmp/claude/ccx-`. +- **git-over-SSH can't authenticate.** Private keys are read-denied and + the ssh-agent launchd socket is blocked. `_ccx_run` forwards + `SSH_AUTH_SOCK` and injects it into `network.allowUnixSockets` in a + per-launch config copy. The socat transport itself works: it reached + github.com through the proxy. + - The `.pub` carve-out fix in section 1 is what keeps the + `IdentitiesOnly yes` agent-signing fallback working. ## 6. Still-open ideas from the v0.0.70 writeup From 92c9e67daf7b22f8b56334a87751e207e918866f Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Tue, 27 Oct 2026 13:57:17 -0700 Subject: [PATCH 17/18] fix(ltic): follow upstream's zod/v3 import; accept the -ltic version suffix config-loader.ts walked SandboxRuntimeConfigSchema with the root 'zod' types while upstream #590 moved sandbox-config.ts to 'zod/v3', which tsc rejected as excessively deep. The instanceof checks also need the same module instance at runtime. The srt-proxy --version test required a bare X.Y.Z; the fork ships X.Y.Z-ltic.N, so allow a prerelease suffix. --- src/utils/config-loader.ts | 2 +- test/proxy-cli.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/config-loader.ts b/src/utils/config-loader.ts index 0a1ef5dda..8341c2a2a 100644 --- a/src/utils/config-loader.ts +++ b/src/utils/config-loader.ts @@ -1,5 +1,5 @@ import * as fs from 'fs' -import { z } from 'zod' +import { z } from 'zod/v3' import { SandboxRuntimeConfigSchema, type SandboxRuntimeConfig, diff --git a/test/proxy-cli.test.ts b/test/proxy-cli.test.ts index 6f18e582b..5bfbfba80 100644 --- a/test/proxy-cli.test.ts +++ b/test/proxy-cli.test.ts @@ -369,7 +369,7 @@ describe('srt-proxy --version', () => { test('prints one line, the name and the package version, and exits 0', () => { expect(manifest.version).toMatch( - /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/, + /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$/, ) expect(runToExit(['--version'])).toEqual({ status: 0, From c3611d4c56a20c993b1683273b5f567fc753c09f Mon Sep 17 00:00:00 2001 From: Greg Smolyn Date: Tue, 27 Oct 2026 14:02:26 -0700 Subject: [PATCH 18/18] =?UTF-8?q?docs(ltic):=20v0.0.77=20=E2=86=92=20v0.0.?= =?UTF-8?q?81=20sync=20notes=20and=20guidelines-repo=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.77_to_0.81_NEW_FEATURES.md: fork patch outcomes (socat GIT_SSH_COMMAND retired for upstream #681), macOS-relevant behaviour changes, options to consider (requireHostMatch, SSH denies), library-level additions, Linux and Windows summaries. HANDOFF_sandbox-runtime-config_v0.0.81.md: self-contained brief for updating ltic-ai-guidelines/sandbox-runtime-config — verified outputs, file-by-file edits (README, DETAILS, justfile, configs) and an acceptance checklist. --- 0.77_to_0.81_NEW_FEATURES.md | 180 ++++++++++++++++++ HANDOFF_sandbox-runtime-config_v0.0.81.md | 221 ++++++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 0.77_to_0.81_NEW_FEATURES.md create mode 100644 HANDOFF_sandbox-runtime-config_v0.0.81.md diff --git a/0.77_to_0.81_NEW_FEATURES.md b/0.77_to_0.81_NEW_FEATURES.md new file mode 100644 index 000000000..70efa1356 --- /dev/null +++ b/0.77_to_0.81_NEW_FEATURES.md @@ -0,0 +1,180 @@ +# sandbox-runtime v0.0.77 → v0.0.81: What's New and What It Means for the ccx Configs + +Status: `ltic-main` is merged up to upstream `main` at **v0.0.81 + 4 commits** (98a4016, +130 commits since v0.0.77), version `0.0.81-ltic.1` (check with `srt --version`). +Build and typecheck are clean. `npm test`: 3400 pass, 1179 skipped (Linux/Windows only), 28 fail. Every remaining failure +also fails on a pristine upstream/main checkout on this Mac (Bun 1.4.4 here vs 1.4.2 +in upstream CI for the 24 non-ASCII request-target tests and 2 `ambiguousRequestTarget` +tests; macOS 26's `/bin/tcsh` and `/bin/csh` for the 4 `sshConnectScript` / git-over-ssh +shell tests). None touch fork code. + +Fork patch outcomes this sync: + +| Fork patch | Outcome | +|---|---| +| `network.allowAllDomains` | **Kept.** Merged with zero conflicts; the block still sits after the `deniedDomains` loop and before `allowedDomains`, `strictAllowlist`, the new per-command lists and the ask callback. | +| Violations file (`SRT_VIOLATIONS_FILE`) in `cli.ts` | **Kept.** Re-seated after upstream's new interrupt handling around `initialize()`. | +| Unrecognized-settings-key warning | **Kept.** Had to follow upstream's move to `import { z } from 'zod/v3'`, or tsc rejects the schema walk and the `instanceof` checks would miss at runtime. | +| macOS socat `GIT_SSH_COMMAND` (PR #452) | **Retired.** Upstream #681 does authenticated git-over-SSH on macOS with stock `/usr/bin/nc`. socat is no longer a dependency. | +| Version read from `package.json` via `createRequire` | **Dropped.** Upstream #630 uses a static JSON import. | + +**No config changes are required.** Both `~/.srt-claude-*.json` load with no warnings and +pass the egress, deny-reason, metadata, `gh api user` and git-over-SSH smoke tests +unchanged. Optional hardening is listed in section 4. + +The deployed `srt` (nvm global) is a symlink to this checkout, so `npm run build` here +already upgraded ccx. The guidelines repo still has to be updated: see +`HANDOFF_sandbox-runtime-config_v0.0.81.md`. + +--- + +## 1. socat is gone: git-over-SSH on macOS now uses `/usr/bin/nc` + +Upstream #681 (v0.0.80). When the proxy wants a token and the SOCKS and HTTP listeners +are the same port (always true for the CLI's mux proxy), macOS gets two variables: + +``` +SRT_SSH_PROXY_COMMAND=LC_ALL=C; no() { ... }; ... | /usr/bin/nc 127.0.0.1 | ... +GIT_SSH_COMMAND=ssh -o ControlMaster=no -o ControlPath=none -o ProxyCommand="/bin/sh -c 'eval \"\${SRT_SSH_PROXY_COMMAND:?unset, but GIT_SSH_COMMAND needs it}\"' sh '%h' %p" +``` + +The script sends an authenticated `CONNECT host:port` with `Proxy-Authorization: Basic …` +over nc, reads the response head, and either execs `cat` to relay or prints the status +line to stderr and exits. The token travels in a variable, not in ssh's argv. Without a +token (external SOCKS proxy) the old `nc -X 5` spelling is still used. + +Verified through the real `_ccx_run` wrapper with `/opt/homebrew/bin` off `PATH`: + +``` +$ git ls-remote git@github.com:ubc/sandbox-runtime.git HEAD # denyall and allowall +17e438ba09021a80ffbb4bcb1ff4f340bdf158a3 HEAD +$ eval "$GIT_SSH_COMMAND -T git@github.com" # README recipe for plain ssh +Hi smolyn-ubc! You've successfully authenticated, but GitHub does not provide shell access. +$ eval "$GIT_SSH_COMMAND -T git@gist.github.com" # deny reason arrives in-band +sandbox proxy: gist.github.com:22: 403 Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead. +``` + +Bare `ssh github.com` still fails with "Could not resolve hostname" as before: only the +`GIT_SSH_COMMAND` spelling carries the ProxyCommand. + +## 2. Refused CONNECTs say why, in the status line + +Also #681. The `403` that answers a refused CONNECT carries the deny reason as its status +phrase, cleaned the way violation lines are (non-ASCII becomes `?`). `curl -I` shows it +directly, ssh prints it via the script above, git prints it as the proxy error. Example: + +``` +$ curl -sI https://gist.github.com | head -1 +HTTP/1.1 403 Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead. +``` + +The violations file gets the same line (`deny network-outbound gist.github.com:443 (…)`), +so the agent now sees the reason twice: in the tool output and in the PostToolUse hook. +Filesystem denials still reach the agent only through the hook. + +## 3. Behaviour changes relevant on macOS (no config edit needed) + +- **Node ≥ 22.12 is required** (#636). Local Node is 24. +- **Glob brackets** (#620–#622, #687, #718): a path entry whose characters read as a + pattern is also read as the path it spells, where that exists; brackets that spell no + character set (`[user-id]`, `backup[2024-01-15]`, `list[].ts`) are text; a `*` or `?` + inside brackets is a member of the set. New `{ "path": "...", "literal": true }` form in + all four filesystem lists marks an entry as a name whatever it holds. Neither ccx config + has bracketed paths. The types changed accordingly (`FilesystemPathEntry[]`), but the + fork's unrecognized-key walker skips unions, so the object form is not flagged. +- **Read rules apply under both spellings of a path** on macOS (#680): as written and + with the symlinks on the way resolved. +- **`credentials.files` entries beat `allowRead`/`allowWrite`** on every platform (#680, + #686). ccx does not use `credentials.files`. +- **Library self-protection** (#595): a wrapped command is write-denied the library's own + install directory, and `checkDependencies()` warns when srt is installed where the + sandbox can write. Under nvm that is `~/.nvm/...`, which neither config grants write + to. No new violation noise observed. +- **TLS termination runs in-process** (#665, #700): no per-tunnel listener; new + `tlsTerminate.maxTunnels` (default 256; a CONNECT past it gets `503` with + `X-Proxy-Error: too-many-tunnels` and a violation) and `handshakeTimeoutMs` (default + 10000). Needs Node or Bun ≥ 1.4. +- **Proxy robustness**: an upstream that answers with an invalid status line gets `502` + instead of crashing the proxy (#660); an upstream that sends more than its + `Content-Length` no longer loses its answer (#717); certificate serials never start with + a zero byte (#685). +- **Temp files**: `process.exit()` or a failed `initialize()` no longer leaves the + library's temp objects (a generated CA key among them) behind (#670). ccx uses a + persistent CA via `caCertPath`/`caKeyPath`, unaffected. +- **Ask callback** only allows on `true` (#591). The CLI has no callback; no impact. + +## 4. New options worth considering for the ccx configs + +- **`network.requireHostMatch: true`** (#663): answer `421` to a request whose Host + header, SNI or absolute-form authority does not name the host and port it is sent to. + Refusals are recorded as violations. Cheap hardening around credential injection. + Recommended to trial in `denyall` first, then both, and re-run `just check` plus + npm/cargo/uv/gh smoke tests. +- **`deniedDomains: ["github.com:22", "ssh.github.com"]`** with reasons: closes the SSH + path that bypasses token masking. Now that the reason reaches ssh in-band (section 1) + the failure is self-explanatory. Still a policy choice: SSH is the documented push path + today. +- **`ignoreViolations` noise**: this sync's smoke runs showed three recurring benign + lines from ssh and its `$SHELL -c` ProxyCommand hop under `denyall`: + `mach-lookup com.apple.dnssd.service`, `mach-lookup com.apple.usymptomsd`, and + `file-read-data /Users//.zshenv`. Optional additions. + +Not adopting, and why: + +- `refuseOpaqueTunnels` refuses every CONNECT the proxy cannot see into, SSH included. +- `stripResponseHeaders`, `allowPlaintextHeaderSet`, `ambiguousRequestTarget` only + matter with a `filterRequest` callback, which is a library API. +- `credentials.files` masking still degrades to deny on macOS. + +## 5. New upstream capabilities (library / embedder level) + +These do not reach the CLI configs but change the "future directions" picture: + +- **Per-command network allow lists** (#591): `registerCommandNetworkLists(commandId, + { allowedDomains })`, keyed by the proxy username the wrap presents. The primitive for + "npm reaches only `*.npmjs.org`". +- **`filterRequest` can rewrite and observe** (#663): an allow may carry + `removeHeaders`, `setHeaders`, `onResponse`; a deny may choose its status; the callback + gets `scheme`, `sni`, `target`, `requestTarget`, `rawHeaders`. URL-level policy. +- **`srt proxy` / `srt-proxy`** (#666, #668): the HTTP proxy alone, with an external + decider process answering every request (`docs/srt-proxy.md`). The "external policy + engine" idea, upstreamed. +- **`network.ambiguousRequestTarget`** (#698): `refuse` (default) / `asSpelled` / + `normalize` for targets that name another path once normalised. +- New export `realPathOf` (#683); `writeRootsOf()` for every write root (#621). + +## 6. Linux-only changes (for the "Using on Linux" notes) + +- **Sandboxed commands stay in their namespaces** (#588, #617): no new user or mount + namespaces by default, so Chromium/Electron's sandbox, rootless podman/buildah, nested + bubblewrap and `unshare -r` fail inside. `allowNestedUserNamespaces: true` lifts it (at + the cost of the write denies against a hostile command). Needs the seccomp helper, or + bubblewrap ≥ 0.8.0 not installed setuid. +- **`denyWrite` beats `allowWrite` however nested** (#684): `denyWrite: ["~"]` makes a + project under `~` read-only; `denyWrite: ["/tmp"]` makes the sandbox's own `TMPDIR` + read-only. +- **Dangerous names** (`.bashrc`, `.git/config`, `.vscode/`) are write-denied one level + below the working directory whatever else the tree holds (#629). +- **Glob walk** is ~6× faster, yields to the event loop every 10 ms, honours the + `AbortSignal`, and restarts if the config or cwd changes mid-wrap (#627, #628, #638, + #644, #645, #655, #679). +- **Fixes**: bubblewrap 0.4 starts again (#580); a command starts only once both relays + listen (#639); relays fall back to IPv4 without IPv6 (#719); `AF_VSOCK` is refused + (#691); a file above a write deny is kept read-only (#705); empty placeholder mounts are + removed when the last sandbox ends (#671); `denyRead` patterns work on file systems + whose listings carry no entry types (#672). + +## 7. v0.0.81 is Windows-only + +Write-restricted token, profile read grant (`srt-win profile …`), `--allow-loopback`, +AppContainer and stdin-pipe escapes closed, kept ACL grants, lower-case-only proxy +variables, `NODE_USE_ENV_PROXY=1`. Nothing changes on macOS or Linux. + +## 8. Things noticed during the sync + +- The guidelines repo's `just check` requires socat on PATH; that check must be relaxed + (see the handoff document). +- `test/proxy-cli.test.ts` asserted a bare `X.Y.Z` version; the fork now allows a + prerelease suffix there. +- Upstream PRs #283 (allowAllDomains), #284 (denyReadAlways) and #452 (socat) are all + still open. #284 and #452 are superseded and can be closed. diff --git a/HANDOFF_sandbox-runtime-config_v0.0.81.md b/HANDOFF_sandbox-runtime-config_v0.0.81.md new file mode 100644 index 000000000..b16621940 --- /dev/null +++ b/HANDOFF_sandbox-runtime-config_v0.0.81.md @@ -0,0 +1,221 @@ +# Handoff: update `ltic-ai-guidelines/sandbox-runtime-config` for srt `0.0.81-ltic.1` + +Audience: an agent working in `~/ltic/aitools/ltic-ai-guidelines/` with no other context. +Goal: one PR that brings `sandbox-runtime-config/` (README.md, DETAILS.md, justfile, the two +`.srt-claude-*.json`, `.zshrc.example`, `.bashrc.example`, `srt-violations.mjs`) in line with +the fork release described below. Everything stated here as verified was run on 2026-10-27 +against the built fork, through the repo's own `_ccx_run` wrapper and the deployed configs. + +## 1. Ground truth + +- The fork `ubc/sandbox-runtime`, branch `ltic-main`, moved from `0.0.77-ltic.1` to + **`0.0.81-ltic.1`**: upstream `anthropic-experimental/sandbox-runtime` main at v0.0.81 + 4 + commits. Release notes: upstream `releases/tag/v0.0.78`, `v0.0.79`, `v0.0.80`, `v0.0.81`. + The fork's own write-up is `0.77_to_0.81_NEW_FEATURES.md` next to this file. +- **No mandatory config changes.** Both repo configs are byte-identical to the deployed + `~/.srt-claude-denyall.json` and `~/.srt-claude-allowall.json` apart from JSON formatting, + and both load on the new build with no "unrecognized settings key" warning. +- **One fork delta is retired: the macOS socat `GIT_SSH_COMMAND`.** Upstream v0.0.80 (#681) + does authenticated git-over-SSH on macOS with stock `/usr/bin/nc`. **socat is no longer a + dependency** of the ccx setup. The fork now has three deltas, not four: `allowAllDomains`, + the violations file, the unrecognized-key warning. +- **One new prerequisite: Node ≥ 22.12** (upstream `engines`). +- **Optional hardening** became available: `network.requireHostMatch`. See section 4. +- The `-ltic` version check, the violations hook (`srt-violations.mjs`), the shell wrapper + files and the CA trust steps are unchanged. + +## 2. Verified behaviour you may document as fact + +Build: `npm install && npm run build && npm run typecheck` clean. `npm test`: 3400 pass, 1179 skipped (Linux/Windows only), 28 fail; +every failure also fails on pristine upstream on the same Mac (Bun 1.4.4 vs upstream CI's +1.4.2; macOS 26's tcsh/csh), none in fork code. + +``` +$ srt --version +0.0.81-ltic.1 +$ srt --settings ~/.srt-claude-denyall.json -- true # and allowall: exit 0, no output +``` + +Inside `_ccx_run ~/.srt-claude-denyall.json sh -c '…'` (so: real wrapper, per-launch config +with `allowUnixSockets` for the ssh-agent socket and the persistent CA paths): + +``` +SRT_VIOLATIONS_FILE=/Users//.local/state/srt/violations-.log +GIT_SSH_COMMAND=ssh -o ControlMaster=no -o ControlPath=none -o ProxyCommand="/bin/sh -c 'eval \"\${SRT_SSH_PROXY_COMMAND:?unset, but GIT_SSH_COMMAND needs it}\"' sh '%h' %p" +SRT_SSH_PROXY_COMMAND=LC_ALL=C; no() { printf "sandbox proxy: %s\n" "$*" >&2; exit 1; }; … | /usr/bin/nc 127.0.0.1 | … + +$ curl -s -o /dev/null -w '%{http_code}' https://example.com/ → 200 +$ curl --noproxy '' -s -o /dev/null -w '%{http_code}' http://169.254.169.254/ → 403 +$ curl -sI https://gist.github.com | head -1 +HTTP/1.1 403 Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead. + +$ gh api user --jq .login → +$ PATH=/usr/bin:/bin command -v socat → (nothing) +$ git ls-remote git@github.com:ubc/sandbox-runtime.git HEAD → HEAD (exit 0) +$ eval "$GIT_SSH_COMMAND -T git@github.com" +Hi ! You've successfully authenticated, but GitHub does not provide shell access. +$ eval "$GIT_SSH_COMMAND -T git@gist.github.com" +sandbox proxy: gist.github.com:22: 403 Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead. +Connection closed by UNKNOWN port 65535 +``` + +Same results for `git ls-remote` and `gh api user` under `~/.srt-claude-allowall.json`. +The git-over-SSH runs had `/opt/homebrew/bin` removed from `PATH` inside the sandbox, so +socat was not reachable: the ProxyCommand uses `/bin/sh`, `/usr/bin/nc` and `/bin/cat` by +absolute path. + +Violations file after that session (denyall): + +``` +… deny network-outbound gist.github.com:443 (Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead.) +… deny network-outbound 169.254.169.254:80 (host is not on the allow list) +… ssh() deny(1) mach-lookup com.apple.dnssd.service +… ssh() deny(1) mach-lookup com.apple.usymptomsd +… ssh() deny(1) file-read-data /Users//.ssh/id_ed25519-github-ubc ← expected: agent signs, key file stays denied +… zsh() deny(1) file-read-metadata /Users//.zshenv ← ssh runs ProxyCommand via $SHELL -c +``` + +Bare `ssh -T git@github.com` (without the `eval "$GIT_SSH_COMMAND …"` recipe) fails with +`Could not resolve hostname github.com`, exactly as before this release. + +## 3. File-by-file edits + +### `README.md` + +1. **Step 1 "Install the patched `srt` from the fork"** (currently "plus three more deltas: + … and git-over-SSH works on macOS via an auth-capable socat ProxyCommand"): + make it two more deltas (violations file, unrecognized-key warning) and drop the socat + clause. Keep the PR #283 link for `allowAllDomains`. +2. Same step, the version line `srt --version # → 0.0.77-ltic.1` → `0.0.81-ltic.1`. +3. Same step, **delete the whole paragraph** "Also install socat (`just deps`) — it's what + carries sandboxed SSH through the authenticated proxy …" and change the code block + `brew install socat jq` → `brew install jq`. +4. Add a **prerequisite line**: Node ≥ 22.12 (the nvm note in that step is the natural + place; `node --version` as the check). Say that an older Node fails at `npm install` + with an `engines` error. +5. **"Git, SSH and GitHub"** bullet "**git-over-SSH works via the forwarded ssh-agent** — + needs socat and keys loaded with `ssh-add` …": remove "needs socat and". Add one + sentence: a destination refused by `deniedDomains` now prints its configured reason on + ssh's stderr (`sandbox proxy: :22: 403 `), and `git` shows the same line. + Keep the `eval "$GIT_SSH_COMMAND git@github.com"` recipe; it is verified. +6. **"Upgrading"**: no structural change; the pointer to "upgrade notes by version" covers + the new DETAILS entry. Optionally note that `brew uninstall socat` is safe afterwards + if nothing else uses it. +7. **"Shared between both"** (the postures section): if Greg adopts `requireHostMatch` + (section 4), add one bullet describing it. Otherwise no change. + +### `DETAILS.md` + +1. **"## The fork"**: "Four deltas over upstream `srt`:" → three. Delete item 4 ("Working + git-over-SSH on macOS … PR #452 …"). Add a short "Retired deltas" note after the list, + beside the existing denyReadAlways history if there is one: `denyReadAlways` (retired at + 0.0.77, upstream deny-within-allow) and the macOS socat `GIT_SSH_COMMAND` (retired at + 0.0.81, upstream #681 nc-based ProxyCommand; PR #452 superseded). +2. **New "### Upstream changes in 0.0.81"** after "### Upstream changes in 0.0.77", same + bullet style. Content (macOS-relevant; PR numbers in parentheses): + - git-over-SSH on macOS uses stock `/usr/bin/nc`: `SRT_SSH_PROXY_COMMAND` holds an + authenticated `CONNECT` script, `GIT_SSH_COMMAND`'s ProxyCommand evals it; the token + never appears in ssh's argv (#681). + - A refused CONNECT carries the deny reason as the `403` status phrase, so curl, git and + ssh print it in-band; the violations file still gets the line too (#681). + - Path entries with glob characters are also read as the path they spell where it + exists; brackets that spell no character set are text; `{ "path": …, "literal": true }` + marks an entry literal in any filesystem list (#620–#622, #687, #718). + - macOS read rules apply under both spellings of a path, as written and realpath'd + (#680). `credentials.files` entries beat `allowRead`/`allowWrite` everywhere (#680, #686). + - The sandbox write-denies srt's own install directory, and `checkDependencies()` warns + if srt is installed somewhere the sandbox can write (#595). + - TLS termination runs in-process; new `network.tlsTerminate.maxTunnels` (default 256, + past it a CONNECT gets `503 X-Proxy-Error: too-many-tunnels` and a violation) and + `handshakeTimeoutMs` (default 10000) (#665, #700). + - New `network.requireHostMatch`, `refuseOpaqueTunnels`, `stripResponseHeaders`, + `allowPlaintextHeaderSet`, `ambiguousRequestTarget` (#663, #698); see section 4 for + which apply here. + - Proxy: invalid upstream status lines get `502` instead of crashing the proxy (#660); + over-long bodies are passed whole (#717); cert serials never start with 0x00 (#685). + - Node ≥ 22.12 (#636). The ask callback only allows on `true` (#591; the CLI has none). +3. **"### Upgrade notes by version"**: add, above the 0.70 → 0.77 entry: + `**0.0.77-ltic.1 → 0.0.81-ltic.1.** No config migration. socat is no longer needed + (`just deps` and `just check` stop asking for it; `brew uninstall socat` is optional). + Node must be ≥ 22.12. After `just upgrade`, `just check` should pass unchanged; if the + configs were customized, nothing in them needs to move. Optional: add + `"requireHostMatch": true` under `network` in both configs (below).` +4. **"## SSH" → "Transport." paragraph**: replace "srt sets `GIT_SSH_COMMAND` with a + `ProxyCommand`; with socat on PATH (fork delta 4) that is an authenticated HTTP CONNECT + tunnel — without socat srt falls back to `nc`, which can't authenticate, and + git-over-SSH dies at the handshake." with: srt sets `GIT_SSH_COMMAND` whose + `ProxyCommand` runs `/bin/sh -c 'eval "$SRT_SSH_PROXY_COMMAND" sh %h %p'`; that script + (also in the environment) sends an authenticated `CONNECT %h:%p` through `/usr/bin/nc` + to the session proxy and relays with `cat`. Nothing beyond what macOS ships is needed. + A refused destination prints `sandbox proxy: host:22: 403 `. Keep the rest of + the paragraph (opaque pass-through, `deniedDomains` still apply, the `eval` recipe). +5. **"## Using on Linux"**: add the Linux-only changes: sandboxed commands can no longer + create user or mount namespaces by default (Chromium/Electron sandboxes, rootless + podman/buildah, nested bwrap and `unshare -r` fail inside; `allowNestedUserNamespaces: + true` lifts it, at the cost of write-deny enforcement against a hostile command; needs + the seccomp helper or bwrap ≥ 0.8.0 not setuid) (#588, #617); `denyWrite` now beats + `allowWrite` however nested, so `denyWrite: ["~"]` makes a project under `~` read-only + (#684); the read-deny glob walk is ~6× faster and no longer blocks the event loop + (#627 ff.); bubblewrap 0.4 works again (#580); the first network call of a command is + no longer refused because the in-sandbox relays were not yet listening (#639). +6. **"## Future directions"**: refresh. Three of the five ideas now have upstream + primitives, all library-level (not reachable from `srt --settings` configs): per-tool + egress via `SandboxManager.registerCommandNetworkLists(commandId, { allowedDomains })` + (#591); URL/header-level policy via `filterRequest` with `setHeaders`/`removeHeaders`/ + `onResponse` (#663); an external policy engine via `srt proxy` / `srt-proxy`, the HTTP + proxy alone with an external decider process (`docs/srt-proxy.md`, #666). Keep "egress + audit logging" as still open. Note `credentials.files` masking is still Linux-only. +7. **"0.0.62-ltic.1 → later"** entry mentions "socat and jq became dependencies": leave as + history, but it is fine to append "(socat dropped again at 0.0.81)". + +### `justfile` + +1. **`deps`**: the loop `for f in socat jq; do …` → `for f in jq; do …`. Update the recipe + comment "install socat + jq via Homebrew" accordingly. Add a Node version check, e.g. + `node -e 'const [M,m]=process.versions.node.split(".").map(Number); process.exit(M>22||(M===22&&m>=12)?0:1)' && echo " ok node $(node --version)" || echo " FAIL node $(node --version) — srt 0.0.81 needs >= 22.12"`. +2. **`check` step 1 "tools"**: `for t in srt socat jq node openssl` → drop `socat`; add the + same Node floor check. +3. **`check`, optional new step** after the `gh api user` smoke test: when `ssh-add -l` + exits 0, run + `_ccx_run "$deny" sh -c 'eval "$GIT_SSH_COMMAND -T git@github.com" 2>&1'` and pass on + output containing `successfully authenticated` (ssh exits 1 there; judge the text, not + the status). Skip with a note when the agent holds no keys. +4. `upgrade`, `install-*`, `trust-ca` unchanged. + +### `.srt-claude-denyall.json`, `.srt-claude-allowall.json` + +No required edits. Two optional, separately decided additions (ask Greg; default is to +leave them out of this PR unless told otherwise): + +- Hardening, both files, under `network`: `"requireHostMatch": true`. Verify afterwards + with `just check` and a few tool runs (`npm view`, `cargo search`, `uv pip install + --dry-run`, `gh api user`); a tool that sends a Host header or SNI not matching its + CONNECT target would now get `421` and a violation line. +- Policy, both files: `"github.com:22"` and `"ssh.github.com"` in `deniedDomains` with + matching `deniedDomainReasons` (e.g. "SSH to GitHub is blocked; use an https remote, + gh auth is injected by the proxy"). Closes the SSH path that bypasses token masking. + Only sensible if the documented push path moves to HTTPS remotes. +- Noise (optional): the ssh hop produced three benign denials under `denyall` + (`mach-lookup com.apple.dnssd.service`, `mach-lookup com.apple.usymptomsd`, + `/.zshenv` read). They may be added to `ignoreViolations["*"]` if they annoy. + +### `.zshrc.example`, `.bashrc.example`, `srt-violations.mjs` + +No changes. The wrapper's per-launch `allowUnixSockets` and CA-path injection, the +credential seeding and the hook are all exercised by the verified runs above. + +## 4. Acceptance checklist for the PR + +- [ ] `just check` passes **with socat uninstalled or hidden from PATH**. +- [ ] `srt --version` → `0.0.81-ltic.1` and `just check` reports it as the fork. +- [ ] Both configs load with no "unrecognized settings key" warning. +- [ ] `ccx_exec gh api user --jq .login` prints the login. +- [ ] `ccx_exec sh -c 'eval "$GIT_SSH_COMMAND -T git@github.com"'` prints "successfully + authenticated" (needs a key in the agent). +- [ ] `ccx_exec curl -sI https://gist.github.com | head -1` shows the configured reason in + the 403 line, and the same line appears in `$SRT_VIOLATIONS_FILE`. +- [ ] README no longer mentions socat as a requirement; DETAILS lists three fork deltas, + has the 0.0.81 upstream-changes section, the 0.77 → 0.81 upgrade note, the rewritten + SSH transport paragraph, and the refreshed Linux and future-directions sections. +- [ ] If `requireHostMatch` was adopted: both configs carry it and the tool smoke tests + above still pass.