Commit Graph

196 Commits

Author SHA1 Message Date
David Dworken
d34c93c5f4 Fix .git/hooks deny path breaking git worktrees and non-git directories
When .git is a file (worktrees) or doesn't exist, the mandatory deny for
.git/hooks caused bwrap failures or turned .git into a /dev/null file.
Three fixes: (1) skip denies when a path ancestor is a file, (2) mount
empty directories instead of /dev/null for intermediate non-existent
components, (3) only add .git/hooks and .git/config denies when .git is
a directory. Also updates cleanup to handle empty directory mount points.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 02:15:33 +00:00
David Dworken
8235d55339 chore: bump version to 0.0.37 2026-02-08 20:39:05 -08:00
David Dworken
0dc4322cda Re-introduce non-existent deny path protection with mount point cleanup
PR #80 hardened the sandbox by mounting /dev/null over non-existent deny
paths to prevent their creation, but this caused bwrap to leave empty
"ghost dotfiles" on the host (issue #85), which PR #91 reverted. This
re-introduces the protection with proper cleanup: mount points are
tracked and removed via cleanupBwrapMountPoints(). A new lightweight
cleanupAfterCommand() API is exposed on SandboxManager for callers to
invoke after each command, and the srt CLI calls it on child exit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:21:15 -08:00
Jarred Sumner
4fad8fa35d Merge pull request #117 from sosukesuzuki/use-bun-which-for-executable-lookup
feat: use Bun.which for executable lookup when available
2026-02-04 00:42:48 -08:00
Sosuke Suzuki
7c9dbfa8dc ci: add Node.js fallback test for whichSync 2026-02-04 07:58:55 +00:00
Sosuke Suzuki
db0fcaf286 test: add tests for whichSync utility
- Add test/utils/which.test.ts for Bun environment testing
- Add test/utils/which-node-test.mjs for Node.js fallback testing
- Update test/sandbox/linux-dependency-error.test.ts to mock
  globalThis.Bun.which directly instead of using mock.module
- Update test/sandbox/seccomp-filter.test.ts to use whichSync
2026-02-04 07:54:44 +00:00
Sosuke Suzuki
bea0023b1c feat: add whichSync utility to use Bun.which when available
- Add src/utils/which.ts with whichSync function that uses Bun.which
  in Bun runtime and falls back to spawnSync('which', ...) in Node.js
- Replace spawnSync('which', ...) calls with whichSync in:
  - src/sandbox/linux-sandbox-utils.ts (dependency checks, shell lookup)
  - src/sandbox/macos-sandbox-utils.ts (shell lookup)
  - src/sandbox/sandbox-manager.ts (ripgrep check)
  - src/utils/ripgrep.ts (hasRipgrepSync)

This avoids spawning a new process for 'which' lookups when running
in Bun, as Bun.which is a native built-in function.
2026-02-04 07:54:44 +00:00
David Dworken
e401ebceae Merge pull request #116 from anthropic-experimental/dworken/linux-denyread-glob-expansion
Expand denyRead glob patterns to concrete paths on Linux
2026-02-03 10:32:45 -08:00
David Dworken
fd054fc27a Expand denyRead glob patterns to concrete paths on Linux 2026-02-03 09:28:27 -08:00
ollie-anthropic
f5ba41a3d1 chore: upgrade deps and bump to 0.0.34 (#112)
* chore: upgrade lodash-es to 4.17.23 and fix js-yaml vuln

- Upgrade lodash-es from 4.17.21 to 4.17.23
- Fix js-yaml prototype pollution vulnerability (GHSA-mh29-5h37-fv8m)

npm audit now shows 0 vulnerabilities.

* chore: bump version to 0.0.34
2026-02-02 16:32:08 -08:00
David Dworken
bf36e4406c Harden sandbox by removing unnecessary trustd.agent mach-lookup (#108)
* Harden sandbox by removing unnecessary trustd.agent mach-lookup

* Bump version in package-lock.json
2026-02-02 02:22:00 -08:00
ryoppippi
ec0c24c41d Fix README example: move reset() inside exit callback (#54)
The previous example called reset() immediately after spawning the
child process, which would shut down proxy servers before the child
process completes. This caused the sandboxed command to fail.

Move reset() inside the 'exit' event callback to ensure cleanup
happens after the child process terminates.
2026-02-02 01:31:11 -08:00
SUZUKI Sosuke
7a4d699bcd perf: memoize getGlobalNpmPaths to avoid repeated execSync calls (#110)
This function runs `npm root -g` which spawns a subprocess. Since the result
is stable for the process lifetime and the function is called from multiple
fallback paths, caching avoids redundant process spawns.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 01:29:19 -08:00
John Joseph Ugalino
85e1515395 Fix dotfile leak by skipping non-existent deny paths (#91)
Mounting /dev/null over non-existent paths creates empty files on host.
Skip non-existent deny paths instead of mounting /dev/null over them.

Removed:
- Non-existent path protection code that created empty files
- findFirstNonExistentComponent() function
- Non-existent deny path protection tests

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 00:55:47 -08:00
ollie-anthropic
54ecea4d47 Merge pull request #102 from anthropic-experimental/ollie/update-pointers-0.0.32
Update pointers
2026-01-23 15:27:50 -08:00
ollie-anthropic
1c378fdde2 Merge main into ollie/update-pointers-0.0.32 2026-01-23 15:20:43 -08:00
ollie-anthropic
37cd88c011 update pointers 2026-01-23 15:19:17 -08:00
ollie-anthropic
824777d3a4 Merge pull request #101 from anthropic-experimental/ollie/unified-dependency-check
feat: unified checkDependencies API returning SandboxDependencyCheck
2026-01-22 16:09:29 -08:00
ollie-anthropic
4d96141b2b fix: call reset() before initialize() in integration test to ensure clean state 2026-01-22 16:01:08 -08:00
ollie-anthropic
77fb2d273f update pointers 2026-01-22 15:13:25 -08:00
ollie-anthropic
28cc3525b6 perf comment 2026-01-22 14:09:05 -08:00
ollie-anthropic
8a6100a111 fix: update seccomp-filter tests to use checkLinuxDependencies and correct arch values
- Replace hasLinuxSandboxDependenciesSync with checkLinuxDependencies
- Fix process.arch comparisons: x86_64 -> x64, aarch64 -> arm64
- Remove duplicate arch conditions
2026-01-22 14:05:15 -08:00
ollie-anthropic
ad5d35c475 ripgrep check 2026-01-22 12:31:16 -08:00
ollie-anthropic
6e0dcb1b82 feat: unified checkDependencies API returning SandboxDependencyCheck
- Rename DependencyCheck -> SandboxDependencyCheck
- checkDependencies() now returns { errors, warnings } instead of boolean
- Mac: always returns { errors: [], warnings: [] } (no system deps)
- Linux: bwrap/socat missing -> errors, seccomp missing -> warnings
- Unsupported platform: returns errors
- Remove checkLinuxDependencies and getLinuxDependencyStatus from public API
- Remove ripgrep check from checkDependencies (bundled, not a system dep)
- Add comprehensive tests for dependency checking
2026-01-22 12:22:36 -08:00
ollie-anthropic
b07da4039c Merge pull request #100 from anthropic-experimental/ollie/bump-0.29
chore: bump version to 0.0.29
2026-01-21 12:34:09 -08:00
ollie-anthropic
9c9bd59160 chore: bump version to 0.0.29 2026-01-20 22:47:30 -08:00
ollie-anthropic
cc249f0585 Merge pull request #99 from anthropic-experimental/ollie/fix-wsl-platform-check
fix: support WSL2 sandboxing, reject WSL1
2026-01-20 22:42:07 -08:00
ollie-anthropic
d612cb73f2 test: add platform detection tests and export getWslVersion 2026-01-20 22:34:38 -08:00
ollie-anthropic
a7c4fcb97d fix: support WSL2 sandboxing, reject WSL1
WSL2+ uses the same bubblewrap sandboxing as Linux, so it should be
supported. WSL1 does not support bubblewrap properly.

- Add getWslVersion() to detect WSL version from /proc/version
- Update isSupportedPlatform() to check for WSL1 and reject it
- Remove 'wsl' from Platform type (getPlatform returns 'linux' for WSL)
- Update comments to clarify Linux/WSL glob pattern handling
2026-01-20 22:27:53 -08:00
loc
2fbc90af12 Merge pull request #90 from anthropic-experimental/loc/dynamic-config-watching
add fd 3 control channel for dynamic config updates
2026-01-16 17:25:30 -08:00
ollie-anthropic
cd1bf05389 Merge pull request #96 from anthropic-experimental/ollie/update-pointers-2
Update pointers to 0.28
2026-01-14 20:28:12 -08:00
ollie-anthropic
6eb213b33e update pointers 2026-01-14 19:21:03 -08:00
ollie-anthropic
a6b1043415 Merge pull request #95 from anthropic-experimental/bump-version-0.0.27
chore: bump version to 0.0.27
2026-01-14 19:04:23 -08:00
ollie-anthropic
b2f3f728d4 Merge pull request #93 from anthropic-experimental/jacques/srt-dont-log-on-sigint-or-sigterm
fix: exit with code 0 on `SIGINT` or `SIGTERM`
2026-01-14 18:47:57 -08:00
ollie-anthropic
d4761dc924 chore: bump version to 0.0.27 2026-01-14 18:42:21 -08:00
David Dworken
1c7ff21f9b Merge pull request #94 from MarshallOfSound/sam/mitm
feat: add mitm proxy configuration options
2026-01-14 17:33:24 -08:00
Andy Locascio
868a04af2e Replace file watcher with fd 3 control channel for config updates
Security improvement: The file watcher approach had a privilege escalation risk
where any process could write to ~/.srt-settings.json to relax sandbox restrictions.
The new fd 3 control channel is secure because only the parent process can write
to a child's file descriptor.

Changes:
- Add --control-fd <fd> flag to read JSON lines config updates from a file descriptor
- Add loadConfigFromString() for parsing config from the control channel
- Remove watchConfigFile() and CONFIG_FILE_SETTLE_DELAY_MS (security risk)
- Add integration tests for --control-fd functionality
- stdin still passes through to child (interactive commands work)
- --settings flag still works for initial config loading

Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%)
Claude-Steers: 3
Claude-Permission-Prompts: 0
Claude-Escapes: 0
Claude-Plan:
<claude-plan>
# Plan: Replace File Watcher with fd 3 Control Channel (TDD)

## Problem

The current file watcher approach (`~/.srt-settings.json`) has a privilege escalation risk:
- Any process that can write to the config file can relax sandbox restrictions
- A compromised process or sandbox escape could modify the file

## Solution

Replace file watching with fd 3 (file descriptor 3) based config updates. This is secure because:
- Only the parent process can write to the child's fd 3
- A sandboxed process cannot write to srt's fd 3 from outside
- Clean privilege separation tied to process hierarchy
- stdin remains available for the sandboxed command (interactive use still works)

## Design

### Protocol: JSON Lines on fd 3

- Each line is a complete `SandboxRuntimeConfig` JSON object
- Full replacement semantics (not patches)
- Invalid JSON/schema is logged and ignored (keeps old config)
- Empty lines are ignored

Example - Claude Code sends config updates:
```typescript
const srt = spawn('srt', ['--control-fd', '3', '--', 'bash'], {
  stdio: ['inherit', 'inherit', 'inherit', 'pipe']  // fd 3 is a writable pipe
})

// Send config update
srt.stdio[3].write('{"network":{"allowedDomains":["example.com"],"deniedDomains":[]},"filesystem":{"denyRead":[],"allowWrite":[],"denyWrite":[]}}\n')
```

### CLI Changes

Add `--control-fd <fd>` flag:
```bash
# Old (file watching - will be removed)
srt --settings ~/.srt-settings.json -- my-command

# New (fd 3 control) - parent must set up fd 3 as a pipe
srt --control-fd 3 -- my-command

# Interactive use still works (no control channel)
srt --debug bash
```

When `--control-fd` is enabled:
1. srt reads from the specified fd for config updates
2. stdin passes through to child normally (interactive commands work)
3. Each valid config line calls `SandboxManager.updateConfig()`

### stdio Configuration

Current:
```typescript
spawn(sandboxedCommand, { shell: true, stdio: 'inherit' })
```

With `--control-fd 3`:
```typescript
// stdin still inherited - interactive commands work!
spawn(sandboxedCommand, { shell: true, stdio: 'inherit' })

// Additionally, srt reads from fd 3 for control messages
const controlStream = fs.createReadStream('', { fd: 3 })
```

### Backward Compatibility

- Remove file watcher entirely (it's a security risk)
- Keep `--settings` flag for initial config loading from file
- Dynamic updates only via `--control-fd`
- Without `--control-fd`, srt works exactly as before (stdin passes through)

## Files to Modify

| File | Changes |
|------|---------|
| `src/cli.ts` | Add `--control-fd` flag, read from fd for config updates, remove file watcher setup |
| `src/utils/config-loader.ts` | Remove `watchConfigFile`, add `loadConfigFromString` |
| `test/cli-config-loading.test.ts` | Remove `watchConfigFile` tests, add `loadConfigFromString` tests |
| `test/control-fd.test.ts` | New file: integration tests for --control-fd |

## TDD Implementation Steps

### Step 1: Write tests for `loadConfigFromString` (RED)

Add to `test/cli-config-loading.test.ts`:
```typescript
describe('loadConfigFromString', () => {
  it('should return null for empty string')
  it('should return null for invalid JSON')
  it('should return null for valid JSON with invalid schema')
  it('should return valid config for valid JSON')
})
```

### Step 2: Implement `loadConfigFromString` (GREEN)

In `src/utils/config-loader.ts`:
```typescript
export function loadConfigFromString(content: string): SandboxRuntimeConfig | null {
  if (!content.trim()) return null
  try {
    const parsed = JSON.parse(content)
    const result = SandboxRuntimeConfigSchema.safeParse(parsed)
    if (!result.success) return null
    return result.data
  } catch {
    return null
  }
}
```

### Step 3: Write integration tests for `--control-fd` (RED)

New file `test/control-fd.test.ts`:
```typescript
describe('--control-fd', () => {
  it('should update config when receiving valid JSON on control fd')
  it('should ignore invalid JSON on control fd')
  it('should allow stdin to pass through to child process')
  it('should work without --control-fd (backward compat)')
})
```

### Step 4: Implement `--control-fd` in CLI (GREEN)

In `src/cli.ts`:
- Add `.option('--control-fd <fd>', 'read config updates from fd (JSON lines)', parseInt)`
- Add readline listener on the control fd
- Call `loadConfigFromString` and `SandboxManager.updateConfig()` for each line

### Step 5: Remove file watcher (REFACTOR)

- Delete `watchConfigFile` and `CONFIG_FILE_SETTLE_DELAY_MS` from `src/utils/config-loader.ts`
- Delete watchConfigFile tests from `test/cli-config-loading.test.ts`
- Remove file watcher setup from `src/cli.ts`

## Verification

1. Run tests after each step: `yarn test`
2. **Integration test** spawns srt with fd 3 pipe and verifies config updates work
3. **Manual test**: `srt --debug bash` still works (interactive stdin)
4. **Manual test**: `srt --control-fd 3` with pipe from parent works

## Security Considerations

- fd 3 control is secure: only parent process can write to it (pipe is unidirectional)
- FD_CLOEXEC ensures sandboxed child never inherits the control fd
- No file system control channel eliminates privilege escalation risk
- stdin still passes through to child - interactive commands work
- If `--control-fd` is not specified, no control channel exists (safe default)
</claude-plan>
2026-01-14 14:35:12 -08:00
Samuel Attard
426a960b36 feat: add mitm proxy configuration options 2026-01-14 14:12:52 -08:00
Jacques Paye
7a51544e7e Don't log an error or exit with 1 on SIGTERM or SIGINT, exit with 0 instead 2026-01-14 16:10:51 -05:00
Andy Locascio
ed768bb786 Add tests for config loading edge cases and updateConfig before init
- Extract loadConfig/watchConfigFile to src/utils/config-loader.ts for testability
- Add CONFIG_FILE_SETTLE_DELAY_MS constant (100ms, increased from 50ms)
- Add test for updateConfig called before initialize
- Add tests for loadConfig edge cases: missing file, empty file, invalid JSON, Zod failures
- Add tests for watchConfigFile: file deletion, invalid updates, rename handling

Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%)
Claude-Steers: 3
Claude-Permission-Prompts: 0
Claude-Escapes: 0
Claude-Plan:
<claude-plan>
# Plan: Fix PR #90 Issues

## Issues to Address
1. Missing tests for config watcher edge cases (file deleted, invalid JSON, Zod failure)
2. Missing test for `updateConfig` before `initialize`
3. Magic number 50ms delay in file watcher
4. Integration tests depend on `example.com`

---

## 1. Remove `example.com` Dependency from Integration Tests

**Problem**: Tests in `test/sandbox/update-config.test.ts` use `curl http://example.com` which requires external network.

**Solution**: The PR already has `proxyRequest()` helper that tests proxy filtering without external network. Replace curl-based integration tests with proxy-based tests.

**File**: `test/sandbox/update-config.test.ts`

**Changes**:
- Remove/refactor tests that spawn `curl http://example.com`
- Keep the `proxyRequest()` tests which already test the same behavior without external network
- The curl tests are redundant since proxy tests verify the same filtering logic

**Tests to modify/remove** (lines ~260-400):
- `should block then allow domain after updateConfig with sandboxed curl` - redundant with proxy test
- `should allow then block domain after updateConfig with sandboxed curl` - redundant with proxy test
- `should handle multiple config updates with sandboxed commands` - redundant with `should handle rapid config updates`
- `should allow network via curl after updateConfig when started with empty allowlist` - keep but convert to proxy-based

---

## 2. Extract and Name the Magic Number

**Problem**: 50ms delay is unexplained and potentially fragile.

**File**: `src/cli.ts`

**Changes**:
```typescript
// At top of file or near watchConfigFile
const CONFIG_FILE_SETTLE_DELAY_MS = 100  // Increased for reliability

// In watchConfigFile, line ~107
setTimeout(() => setupWatcher(), CONFIG_FILE_SETTLE_DELAY_MS)
```

Increase to 100ms for more reliability on slower filesystems.

---

## 3. Add Config Watcher Edge Case Tests

**Problem**: `watchConfigFile` and `loadConfig` are not exported.

**Solution**: Export them for testing, or create a new test file that tests via the CLI.

**Approach**: Export `loadConfig` from cli.ts (it's already useful standalone), create unit tests.

**File**: `src/cli.ts` - add export
**New file**: `test/cli-config-loading.test.ts`

**Tests to add**:
```typescript
describe('loadConfig', () => {
  it('should return null when file does not exist')
  it('should return null for empty file')
  it('should return null and log error for invalid JSON')
  it('should return null and log Zod errors for invalid schema')
  it('should return valid config for valid file')
})

describe('watchConfigFile', () => {
  it('should not call callback when file is deleted')
  it('should not call callback for invalid JSON')
  it('should not call callback for Zod validation failure')
  it('should call callback with new config on valid change')
  it('should re-establish watcher after file replacement (rename)')
})
```

---

## 4. Add Test for `updateConfig` Before `initialize`

**File**: `test/sandbox/update-config.test.ts`

**Test to add**:
```typescript
it('should handle updateConfig called before initialize', async () => {
  // Ensure clean state
  await SandboxManager.reset()

  // updateConfig before initialize - should not throw
  SandboxManager.updateConfig({
    network: { allowedDomains: ['example.com'], deniedDomains: [] },
    filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
  })

  // Config should be set
  expect(SandboxManager.getConfig()).toBeDefined()

  // But network infrastructure not ready
  expect(SandboxManager.getProxyPort()).toBeUndefined()

  // Initialize should still work and respect the pre-set config
  await SandboxManager.initialize({
    network: { allowedDomains: ['other.com'], deniedDomains: [] },
    filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
  })

  // initialize() overwrites config
  const config = SandboxManager.getConfig()
  expect(config?.network.allowedDomains).toContain('other.com')
})
```

---

## Files to Modify

| File | Changes |
|------|---------|
| `src/cli.ts` | Export `loadConfig`, `watchConfigFile`; name magic number constant |
| `test/sandbox/update-config.test.ts` | Remove curl-based tests, add pre-init test |
| `test/cli-config-loading.test.ts` | New file with loadConfig and watchConfigFile tests |

---

## Verification

1. Run full test suite: `yarn test`
2. Verify no tests use external network (grep for `example.com` in test assertions)
3. Verify new edge case tests pass
4. Manual test: modify `~/.srt-settings.json` while srt is running with `--debug` to verify watcher logs
</claude-plan>
2026-01-13 15:33:14 -08:00
Andy Locascio
c39c935099 Fix integration test to handle proxy rejection message
With the proxy always running (even for empty allowlist), curl now gets
a 403 'blocked by network allowlist' response instead of a DNS/connection
error. Updated tests to check for this message.

Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%)
Claude-Steers: 3
Claude-Permission-Prompts: 6
Claude-Escapes: 0
Claude-Plan:
<claude-plan>
# Dynamic Network Egress Rules Configuration

## Summary
Add file watching to the CLI so domain allow/deny rules update automatically when `~/.srt-settings.json` changes.

## Scope
- **CLI only** - the library doesn't read files, it receives config objects
- **No notification callbacks** - silent updates
- **Error handling** - log errors, keep old config (safe)

## Why This Works
The network filtering is already dynamic:
1. `filterNetworkRequest()` reads from module-level `config` on each request
2. `updateConfig()` already exists and replaces `config` via deep clone
3. Proxies don't need restart - they call the filter function per-request

So: **watch file → re-read → validate → updateConfig() → done**

## Implementation Plan

### File: `src/cli.ts`

#### 1. Add file watcher function (after `getDefaultConfig()`, around line 76)
```typescript
/**
 * Watch config file for changes and call callback with new config
 * Returns cleanup function to stop watching
 */
function watchConfigFile(
  configPath: string,
  onUpdate: (config: SandboxRuntimeConfig) => void
): () => void {
  // Only watch if file exists
  if (!fs.existsSync(configPath)) {
    return () => {} // No-op cleanup
  }

  const watcher = fs.watch(configPath, { persistent: false }, (eventType) => {
    if (eventType === 'change') {
      const newConfig = loadConfig(configPath)
      if (newConfig) {
        onUpdate(newConfig)
        logForDebugging(`Config reloaded from ${configPath}`)
      }
      // If loadConfig returns null (invalid), keep old config (already logged)
    }
  })

  return () => watcher.close()
}
```

#### 2. Call watcher after initialize (after line 125, before "Determine command string")
```typescript
// Initialize sandbox with config
logForDebugging('Initializing sandbox...')
await SandboxManager.initialize(runtimeConfig)

// Watch config file for dynamic updates (useful for long-running processes)
const stopWatching = watchConfigFile(configPath, (newConfig) => {
  SandboxManager.updateConfig(newConfig)
})
process.on('exit', stopWatching)

// Determine command string based on mode
```

### Changes Summary
| File | Change |
|------|--------|
| `src/cli.ts:77` | Add `watchConfigFile()` function (~20 lines) |
| `src/cli.ts:126` | Add watcher setup (~4 lines) |
| `test/sandbox/update-config.test.ts` | Add test for `updateConfig()` (~25 lines) |

**No changes to:**
- `sandbox-manager.ts` - `updateConfig()` already exists
- `sandbox-config.ts` - validation already exists
- Library API - stays unchanged

### File: `test/sandbox/update-config.test.ts` (new file)
```typescript
import { describe, it, expect, afterEach } from 'vitest'
import { SandboxManager } from '../../src/index.js'

describe('SandboxManager.updateConfig', () => {
  afterEach(async () => {
    await SandboxManager.reset()
  })

  it('should update network restriction config dynamically', async () => {
    // Initialize with no allowed domains
    await SandboxManager.initialize({
      network: { allowedDomains: [], deniedDomains: [] },
      filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
    })

    // Initial state: no allowed hosts (empty array becomes undefined in getter)
    expect(SandboxManager.getNetworkRestrictionConfig().allowedHosts).toBeUndefined()

    // Update config to allow example.com
    SandboxManager.updateConfig({
      network: { allowedDomains: ['example.com'], deniedDomains: [] },
      filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
    })

    // Config should now reflect the update
    const config = SandboxManager.getNetworkRestrictionConfig()
    expect(config.allowedHosts).toContain('example.com')
  })
})
```

### Note on Use Case
File watching is most useful for **long-running processes** (MCP servers, dev servers, interactive shells). For short commands like `curl`, the command finishes before any config change can be applied. This is expected behavior.

## Edge Cases
1. **File deleted** - `fs.watch` may emit 'rename', ignore it (keep old config)
2. **Invalid JSON** - `loadConfig` throws, catch and log, keep old config
3. **Invalid schema** - Zod validation fails, `loadConfig` returns null, keep old config
4. **Rapid changes** - fs.watch may batch or debounce naturally; consider debouncing if problematic

## Verification
Test with a long-running process to observe dynamic updates:

```bash
# Terminal 1: Start a long-running sandboxed shell
srt --debug bash

# In the sandboxed shell, test blocked domain:
curl example.com  # Should fail (blocked)

# Terminal 2: Edit config to allow example.com
echo '{"network":{"allowedDomains":["example.com"],"deniedDomains":[]},"filesystem":{"denyRead":[],"allowWrite":["."],"denyWrite":[]}}' > ~/.srt-settings.json

# Back in Terminal 1 (same sandboxed shell):
curl example.com  # Should now succeed (config was reloaded)

# Terminal 2: Break the config
echo 'invalid json' > ~/.srt-settings.json
# Debug log should show error, old rules still work

# Terminal 2: Restore valid config
echo '{"network":{"allowedDomains":[],"deniedDomains":[]},"filesystem":{"denyRead":[],"allowWrite":["."],"denyWrite":[]}}' > ~/.srt-settings.json
# example.com should be blocked again
```
</claude-plan>
2026-01-13 14:40:16 -08:00
Andy Locascio
cd92618807 Always route through proxy when network config is specified
Previously, with empty allowedDomains, the sandbox was created without proxy
access. This meant updateConfig() couldn't enable network access for already-
running processes because they had no way to reach the proxy.

Now, whenever network config is specified (even empty allowlist), we route
through the proxy. The proxy blocks all requests when allowlist is empty,
but if updateConfig() adds allowed domains, sandboxed processes can benefit.

Tests added for updateConfig():
- Config state tests
- Proxy filtering tests (raw TCP)
- Integration tests verifying wrapper includes proxy config

Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%)
Claude-Steers: 0
Claude-Permission-Prompts: 0
Claude-Escapes: 0
Claude-Plan:
<claude-plan>
# Dynamic Network Egress Rules Configuration

## Summary
Add file watching to the CLI so domain allow/deny rules update automatically when `~/.srt-settings.json` changes.

## Scope
- **CLI only** - the library doesn't read files, it receives config objects
- **No notification callbacks** - silent updates
- **Error handling** - log errors, keep old config (safe)

## Why This Works
The network filtering is already dynamic:
1. `filterNetworkRequest()` reads from module-level `config` on each request
2. `updateConfig()` already exists and replaces `config` via deep clone
3. Proxies don't need restart - they call the filter function per-request

So: **watch file → re-read → validate → updateConfig() → done**

## Implementation Plan

### File: `src/cli.ts`

#### 1. Add file watcher function (after `getDefaultConfig()`, around line 76)
```typescript
/**
 * Watch config file for changes and call callback with new config
 * Returns cleanup function to stop watching
 */
function watchConfigFile(
  configPath: string,
  onUpdate: (config: SandboxRuntimeConfig) => void
): () => void {
  // Only watch if file exists
  if (!fs.existsSync(configPath)) {
    return () => {} // No-op cleanup
  }

  const watcher = fs.watch(configPath, { persistent: false }, (eventType) => {
    if (eventType === 'change') {
      const newConfig = loadConfig(configPath)
      if (newConfig) {
        onUpdate(newConfig)
        logForDebugging(`Config reloaded from ${configPath}`)
      }
      // If loadConfig returns null (invalid), keep old config (already logged)
    }
  })

  return () => watcher.close()
}
```

#### 2. Call watcher after initialize (after line 125, before "Determine command string")
```typescript
// Initialize sandbox with config
logForDebugging('Initializing sandbox...')
await SandboxManager.initialize(runtimeConfig)

// Watch config file for dynamic updates (useful for long-running processes)
const stopWatching = watchConfigFile(configPath, (newConfig) => {
  SandboxManager.updateConfig(newConfig)
})
process.on('exit', stopWatching)

// Determine command string based on mode
```

### Changes Summary
| File | Change |
|------|--------|
| `src/cli.ts:77` | Add `watchConfigFile()` function (~20 lines) |
| `src/cli.ts:126` | Add watcher setup (~4 lines) |
| `test/sandbox/update-config.test.ts` | Add test for `updateConfig()` (~25 lines) |

**No changes to:**
- `sandbox-manager.ts` - `updateConfig()` already exists
- `sandbox-config.ts` - validation already exists
- Library API - stays unchanged

### File: `test/sandbox/update-config.test.ts` (new file)
```typescript
import { describe, it, expect, afterEach } from 'vitest'
import { SandboxManager } from '../../src/index.js'

describe('SandboxManager.updateConfig', () => {
  afterEach(async () => {
    await SandboxManager.reset()
  })

  it('should update network restriction config dynamically', async () => {
    // Initialize with no allowed domains
    await SandboxManager.initialize({
      network: { allowedDomains: [], deniedDomains: [] },
      filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
    })

    // Initial state: no allowed hosts (empty array becomes undefined in getter)
    expect(SandboxManager.getNetworkRestrictionConfig().allowedHosts).toBeUndefined()

    // Update config to allow example.com
    SandboxManager.updateConfig({
      network: { allowedDomains: ['example.com'], deniedDomains: [] },
      filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
    })

    // Config should now reflect the update
    const config = SandboxManager.getNetworkRestrictionConfig()
    expect(config.allowedHosts).toContain('example.com')
  })
})
```

### Note on Use Case
File watching is most useful for **long-running processes** (MCP servers, dev servers, interactive shells). For short commands like `curl`, the command finishes before any config change can be applied. This is expected behavior.

## Edge Cases
1. **File deleted** - `fs.watch` may emit 'rename', ignore it (keep old config)
2. **Invalid JSON** - `loadConfig` throws, catch and log, keep old config
3. **Invalid schema** - Zod validation fails, `loadConfig` returns null, keep old config
4. **Rapid changes** - fs.watch may batch or debounce naturally; consider debouncing if problematic

## Verification
Test with a long-running process to observe dynamic updates:

```bash
# Terminal 1: Start a long-running sandboxed shell
srt --debug bash

# In the sandboxed shell, test blocked domain:
curl example.com  # Should fail (blocked)

# Terminal 2: Edit config to allow example.com
echo '{"network":{"allowedDomains":["example.com"],"deniedDomains":[]},"filesystem":{"denyRead":[],"allowWrite":["."],"denyWrite":[]}}' > ~/.srt-settings.json

# Back in Terminal 1 (same sandboxed shell):
curl example.com  # Should now succeed (config was reloaded)

# Terminal 2: Break the config
echo 'invalid json' > ~/.srt-settings.json
# Debug log should show error, old rules still work

# Terminal 2: Restore valid config
echo '{"network":{"allowedDomains":[],"deniedDomains":[]},"filesystem":{"denyRead":[],"allowWrite":["."],"denyWrite":[]}}' > ~/.srt-settings.json
# example.com should be blocked again
```
</claude-plan>
2026-01-13 13:54:17 -08:00
Andy Locascio
fd2b68ec36 Add dynamic config file watching to CLI
Watch ~/.srt-settings.json for changes and call SandboxManager.updateConfig()
automatically. Re-establishes watcher after file replacement to handle macOS
rename events when files are atomically replaced.

Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%)
Claude-Steers: 0
Claude-Permission-Prompts: 1
Claude-Escapes: 0
Claude-Plan:
<claude-plan>
# Dynamic Network Egress Rules Configuration

## Summary
Add file watching to the CLI so domain allow/deny rules update automatically when `~/.srt-settings.json` changes.

## Scope
- **CLI only** - the library doesn't read files, it receives config objects
- **No notification callbacks** - silent updates
- **Error handling** - log errors, keep old config (safe)

## Why This Works
The network filtering is already dynamic:
1. `filterNetworkRequest()` reads from module-level `config` on each request
2. `updateConfig()` already exists and replaces `config` via deep clone
3. Proxies don't need restart - they call the filter function per-request

So: **watch file → re-read → validate → updateConfig() → done**

## Implementation Plan

### File: `src/cli.ts`

#### 1. Add file watcher function (after `getDefaultConfig()`, around line 76)
```typescript
/**
 * Watch config file for changes and call callback with new config
 * Returns cleanup function to stop watching
 */
function watchConfigFile(
  configPath: string,
  onUpdate: (config: SandboxRuntimeConfig) => void
): () => void {
  // Only watch if file exists
  if (!fs.existsSync(configPath)) {
    return () => {} // No-op cleanup
  }

  const watcher = fs.watch(configPath, { persistent: false }, (eventType) => {
    if (eventType === 'change') {
      const newConfig = loadConfig(configPath)
      if (newConfig) {
        onUpdate(newConfig)
        logForDebugging(`Config reloaded from ${configPath}`)
      }
      // If loadConfig returns null (invalid), keep old config (already logged)
    }
  })

  return () => watcher.close()
}
```

#### 2. Call watcher after initialize (after line 125, before "Determine command string")
```typescript
// Initialize sandbox with config
logForDebugging('Initializing sandbox...')
await SandboxManager.initialize(runtimeConfig)

// Watch config file for dynamic updates (useful for long-running processes)
const stopWatching = watchConfigFile(configPath, (newConfig) => {
  SandboxManager.updateConfig(newConfig)
})
process.on('exit', stopWatching)

// Determine command string based on mode
```

### Changes Summary
| File | Change |
|------|--------|
| `src/cli.ts:77` | Add `watchConfigFile()` function (~20 lines) |
| `src/cli.ts:126` | Add watcher setup (~4 lines) |
| `test/sandbox/update-config.test.ts` | Add test for `updateConfig()` (~25 lines) |

**No changes to:**
- `sandbox-manager.ts` - `updateConfig()` already exists
- `sandbox-config.ts` - validation already exists
- Library API - stays unchanged

### File: `test/sandbox/update-config.test.ts` (new file)
```typescript
import { describe, it, expect, afterEach } from 'vitest'
import { SandboxManager } from '../../src/index.js'

describe('SandboxManager.updateConfig', () => {
  afterEach(async () => {
    await SandboxManager.reset()
  })

  it('should update network restriction config dynamically', async () => {
    // Initialize with no allowed domains
    await SandboxManager.initialize({
      network: { allowedDomains: [], deniedDomains: [] },
      filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
    })

    // Initial state: no allowed hosts (empty array becomes undefined in getter)
    expect(SandboxManager.getNetworkRestrictionConfig().allowedHosts).toBeUndefined()

    // Update config to allow example.com
    SandboxManager.updateConfig({
      network: { allowedDomains: ['example.com'], deniedDomains: [] },
      filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
    })

    // Config should now reflect the update
    const config = SandboxManager.getNetworkRestrictionConfig()
    expect(config.allowedHosts).toContain('example.com')
  })
})
```

### Note on Use Case
File watching is most useful for **long-running processes** (MCP servers, dev servers, interactive shells). For short commands like `curl`, the command finishes before any config change can be applied. This is expected behavior.

## Edge Cases
1. **File deleted** - `fs.watch` may emit 'rename', ignore it (keep old config)
2. **Invalid JSON** - `loadConfig` throws, catch and log, keep old config
3. **Invalid schema** - Zod validation fails, `loadConfig` returns null, keep old config
4. **Rapid changes** - fs.watch may batch or debounce naturally; consider debouncing if problematic

## Verification
Test with a long-running process to observe dynamic updates:

```bash
# Terminal 1: Start a long-running sandboxed shell
srt --debug bash

# In the sandboxed shell, test blocked domain:
curl example.com  # Should fail (blocked)

# Terminal 2: Edit config to allow example.com
echo '{"network":{"allowedDomains":["example.com"],"deniedDomains":[]},"filesystem":{"denyRead":[],"allowWrite":["."],"denyWrite":[]}}' > ~/.srt-settings.json

# Back in Terminal 1 (same sandboxed shell):
curl example.com  # Should now succeed (config was reloaded)

# Terminal 2: Break the config
echo 'invalid json' > ~/.srt-settings.json
# Debug log should show error, old rules still work

# Terminal 2: Restore valid config
echo '{"network":{"allowedDomains":[],"deniedDomains":[]},"filesystem":{"denyRead":[],"allowWrite":["."],"denyWrite":[]}}' > ~/.srt-settings.json
# example.com should be blocked again
```
</claude-plan>
2026-01-13 13:54:00 -08:00
ollie-anthropic
f5902cd08e Merge pull request #84 from anthropic-experimental/jacques/respect-tmpdir-in-srt
Reuse `process.env.TMPDIR` if set
2026-01-08 20:07:04 +00:00
Jacques Paye
7ddf03cc12 switch to CLAUDE_TMPDIR 2026-01-08 12:03:54 -08:00
Jacques Paye
1370d5d405 reuse TMPDIR if it's set instead of hardcoding /tmp/claude 2026-01-08 12:02:51 -08:00
ollie-anthropic
0d2383bdbd Merge pull request #82 from anthropic-experimental/ollie/update-pointers
Update pointers
2026-01-07 21:00:35 +00:00
ollie-anthropic
0ebff52ac5 update pointers 2026-01-07 20:51:32 +00:00
ollie-anthropic
544ae1c85b Merge pull request #81 from anthropic-experimental/dylanc/seccomp-config-paths
Add seccomp config for custom binary paths
2026-01-07 20:50:12 +00:00
David Dworken
e46838704d Merge pull request #80 from anthropic-experimental/dworken/nonexistent-deny-path-hardening
Harden sandbox deny path handling for non-existent files
2026-01-07 14:39:26 -05:00