Files
sandbox-runtime/test/control-fd.test.ts
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

216 lines
6.4 KiB
TypeScript

import { describe, it, expect, afterEach, beforeEach } from 'vitest'
import { spawn, type ChildProcess } from 'child_process'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { type Writable } from 'stream'
// Get the path to the built CLI
const CLI_PATH = path.join(process.cwd(), 'dist', 'cli.js')
describe('--control-fd', () => {
let tmpDir: string
let child: ChildProcess | null = null
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'control-fd-test-'))
})
afterEach(() => {
if (child && !child.killed) {
child.kill('SIGKILL')
}
fs.rmSync(tmpDir, { recursive: true, force: true })
})
it('should update config when receiving valid JSON on control fd', async () => {
// Create a test script that outputs the current network config
// We'll use the debug output to verify config was updated
const testScript = path.join(tmpDir, 'test.sh')
fs.writeFileSync(testScript, '#!/bin/bash\nsleep 0.3\necho "DONE"\n', {
mode: 0o755,
})
// Spawn srt with --control-fd 3, passing fd 3 as a pipe
child = spawn(
'node',
[CLI_PATH, '--debug', '--control-fd', '3', '--', testScript],
{
stdio: ['inherit', 'pipe', 'pipe', 'pipe'],
env: { ...process.env, SRT_DEBUG: 'true' },
},
)
const stdout: string[] = []
const stderr: string[] = []
child.stdout?.on('data', (data: Buffer) => {
stdout.push(data.toString())
})
child.stderr?.on('data', (data: Buffer) => {
stderr.push(data.toString())
})
// Wait a bit for srt to initialize, then send a config update
await new Promise(r => setTimeout(r, 100))
const controlFd = child.stdio[3] as Writable
const configUpdate = JSON.stringify({
network: { allowedDomains: ['updated-domain.com'], deniedDomains: [] },
filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
})
controlFd.write(configUpdate + '\n')
// Wait for process to complete
await new Promise<void>((resolve, reject) => {
child!.on('exit', () => resolve())
child!.on('error', reject)
setTimeout(() => resolve(), 2000) // Timeout safety
})
// Check that config was updated - look for debug output
const allStderr = stderr.join('')
expect(allStderr).toContain('updated-domain.com')
})
it('should ignore invalid JSON on control fd and continue running', async () => {
const testScript = path.join(tmpDir, 'test.sh')
fs.writeFileSync(testScript, '#!/bin/bash\nsleep 0.3\necho "COMPLETED"\n', {
mode: 0o755,
})
child = spawn(
'node',
[CLI_PATH, '--debug', '--control-fd', '3', '--', testScript],
{
stdio: ['inherit', 'pipe', 'pipe', 'pipe'],
env: { ...process.env, SRT_DEBUG: 'true' },
},
)
const stdout: string[] = []
child.stdout?.on('data', (data: Buffer) => {
stdout.push(data.toString())
})
// Wait a bit for srt to initialize, then send invalid JSON
await new Promise(r => setTimeout(r, 100))
const controlFd = child.stdio[3] as Writable
controlFd.write('{ invalid json }\n')
// Wait for process to complete
await new Promise<void>((resolve, reject) => {
child!.on('exit', () => resolve())
child!.on('error', reject)
setTimeout(() => resolve(), 2000) // Timeout safety
})
// Process should still complete successfully
const allStdout = stdout.join('')
expect(allStdout).toContain('COMPLETED')
})
it('should ignore empty lines on control fd', async () => {
const testScript = path.join(tmpDir, 'test.sh')
fs.writeFileSync(testScript, '#!/bin/bash\nsleep 0.3\necho "DONE"\n', {
mode: 0o755,
})
child = spawn('node', [CLI_PATH, '--control-fd', '3', '--', testScript], {
stdio: ['inherit', 'pipe', 'pipe', 'pipe'],
})
const stdout: string[] = []
child.stdout?.on('data', (data: Buffer) => {
stdout.push(data.toString())
})
// Wait a bit for srt to initialize, then send empty lines
await new Promise(r => setTimeout(r, 100))
const controlFd = child.stdio[3] as Writable
controlFd.write('\n')
controlFd.write(' \n')
controlFd.write('\t\n')
// Wait for process to complete
await new Promise<void>((resolve, reject) => {
child!.on('exit', () => resolve())
child!.on('error', reject)
setTimeout(() => resolve(), 2000) // Timeout safety
})
// Process should still complete successfully
const allStdout = stdout.join('')
expect(allStdout).toContain('DONE')
})
it('should work without --control-fd (backward compat)', async () => {
const testScript = path.join(tmpDir, 'test.sh')
fs.writeFileSync(testScript, '#!/bin/bash\necho "NO_CONTROL_FD"\n', {
mode: 0o755,
})
// Spawn without --control-fd
child = spawn('node', [CLI_PATH, '--', testScript], {
stdio: ['inherit', 'pipe', 'pipe'],
})
const stdout: string[] = []
child.stdout?.on('data', (data: Buffer) => {
stdout.push(data.toString())
})
// Wait for process to complete
const exitCode = await new Promise<number | null>((resolve, reject) => {
child!.on('exit', code => resolve(code))
child!.on('error', reject)
setTimeout(() => resolve(null), 2000) // Timeout safety
})
expect(exitCode).toBe(0)
const allStdout = stdout.join('')
expect(allStdout).toContain('NO_CONTROL_FD')
})
it('should allow stdin to pass through to child process', async () => {
// Create a script that reads from stdin
const testScript = path.join(tmpDir, 'test.sh')
fs.writeFileSync(
testScript,
'#!/bin/bash\nread line\necho "GOT: $line"\n',
{ mode: 0o755 },
)
// Spawn with stdin as pipe (not inherit) so we can write to it
child = spawn('node', [CLI_PATH, '--control-fd', '3', '--', testScript], {
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
})
const stdout: string[] = []
child.stdout?.on('data', (data: Buffer) => {
stdout.push(data.toString())
})
// Write to stdin (fd 0)
const stdin = child.stdin as Writable
stdin.write('hello from stdin\n')
// Wait for process to complete
await new Promise<void>((resolve, reject) => {
child!.on('exit', () => resolve())
child!.on('error', reject)
setTimeout(() => resolve(), 2000) // Timeout safety
})
const allStdout = stdout.join('')
expect(allStdout).toContain('GOT: hello from stdin')
})
})