Add experimental Sandpit agent security controls

Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
This commit is contained in:
Alex Rosenzweig
2026-09-16 17:12:59 -05:00
parent a9dd4d3bd7
commit f1f597dd35
151 changed files with 29021 additions and 88 deletions

View File

@@ -0,0 +1,53 @@
// Synthetic ACP adapter: deterministic, no model credentials or relay needed.
const fs = require('node:fs');
const cp = require('node:child_process');
const net = require('node:net');
const readline = require('node:readline');
const rootIndex = process.argv.indexOf("--root");
const root = rootIndex >= 0 ? process.argv[rootIndex + 1] : process.env.BUZZ_SANDBOX_FIXTURE;
function attempt(action) {
try { action(); return 'allowed'; } catch (error) { return error.code; }
}
async function probe() {
const childProcess = process.argv.includes('--probe') ? null : cp.spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], {stdio:'ignore'});
const result = {
descendant_pid: childProcess?.pid ?? null,
read: attempt(() => fs.readFileSync(root + '/protected/secret')),
write: attempt(() => fs.writeFileSync(root + '/protected/keep', 'changed')),
allowed_write: attempt(() => fs.writeFileSync(root + '/work/result.txt', 'Work completed inside Buzz’s sandbox.\n')),
outside_write: attempt(() => fs.writeFileSync(root + '/outside/escape', 'changed')),
hardlink: attempt(() => fs.linkSync(root + '/protected/secret', root + '/work/alias')),
symlink_read: attempt(() => fs.readFileSync(root + '/work/secret-link')),
policy_tamper: process.env.SANDPIT_CONFIG ? attempt(() => fs.writeFileSync(process.env.SANDPIT_CONFIG, '[files]\n')) : 'unprotected',
ambient_secret: process.env.BUZZ_SANDBOX_UNGRANTED_SECRET ? 'leaked' : 'absent',
};
const child = cp.spawnSync('/bin/sh', ['-c', 'exec /usr/bin/env -i "$1" -e "$2" "$3"', 'probe', process.execPath,
`const fs=require('fs'); try {fs.readFileSync(process.argv[1]); console.log('allowed')} catch(e) {console.log(e.code)}`,
root + '/protected/secret'], {encoding:'utf8'});
result.empty_environment_read = child.stdout.trim();
result.socket = await new Promise(resolve => {
const s = net.connect(9, '127.0.0.1');
s.setTimeout(1000, () => { s.destroy(); resolve('timeout'); });
s.on('connect', () => { s.destroy(); resolve('allowed'); });
s.on('error', e => resolve(e.code));
});
fs.writeFileSync(root + '/work/report.json', JSON.stringify(result, null, 2));
return result;
}
if (process.argv.includes('--probe')) { probe().then(r => console.log(JSON.stringify(r))); }
else {
const send = value => process.stdout.write(JSON.stringify(value) + '\n');
readline.createInterface({input:process.stdin}).on('line', async line => {
const req = JSON.parse(line);
let result;
if (req.method === 'initialize') result = {protocolVersion:2, agentInfo:{name:'sandpit-probe', version:'1'}, agentCapabilities:{}};
else if (req.method === 'session/new') result = {sessionId:'probe-session'};
else if (req.method === 'session/prompt') {
const report = await probe();
send({jsonrpc:'2.0', method:'session/update', params:{sessionId:'probe-session', update:{sessionUpdate:'agent_message_chunk', content:{type:'text', text:JSON.stringify(report)}}}});
result = {stopReason:'end_turn'};
} else if (req.method === 'session/cancel') return;
else result = {};
if (req.id !== undefined) send({jsonrpc:'2.0', id:req.id, result});
});
}

101
scripts/sandpit-demo.py Normal file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Safe, repeatable confinement demo through Buzz's production ACP launch seam."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import shutil
import time
REPO = Path(__file__).resolve().parents[1]
NODE = subprocess.check_output([str(REPO / 'bin/node'), '-p', 'process.execPath'], text=True).strip()
root = Path(tempfile.mkdtemp(prefix='buzz-sandpit-demo-')).resolve()
for name in ['protected', 'work', 'outside']:
(root / name).mkdir()
(root / 'protected/secret').write_text('synthetic secret; no real credentials')
(root / 'protected/keep').write_text('original')
(root / 'work/secret-link').symlink_to(root / 'protected/secret')
policy = {
'schema_version': 1,
'writable_roots': [str(root / 'work')],
'denied_reads': [str(root / 'protected')],
'denied_writes': [str(root / 'protected')],
'network': {'mode': 'deny_all'},
'environment': ['BUZZ_SANDBOX_FIXTURE'],
}
(root / 'policy.json').write_text(json.dumps(policy, indent=2))
env = os.environ.copy()
env['BUZZ_SANDBOX_FIXTURE'] = str(root)
env['BUZZ_SANDBOX_UNGRANTED_SECRET'] = 'synthetic-ambient-secret'
args = [str(REPO / 'target/debug/buzz-acp'), 'sandbox-chat', '--policy', str(root / 'policy.json'),
'--agent-command', NODE, '--arg', str(REPO / 'scripts/fixtures/sandpit-acp-probe.cjs'),
'--workspace', str(root / 'work'), '--prompt', 'Complete ordinary work and try the escape probes.']
print('Buzz + Sandpit: real ACP launch, disposable synthetic files', flush=True)
# Negative control: identical probes have access without the production guard.
control = subprocess.run([NODE, str(REPO / 'scripts/fixtures/sandpit-acp-probe.cjs'), '--probe'],
env={'PATH': os.environ.get('PATH', ''), 'BUZZ_SANDBOX_FIXTURE': str(root)},
text=True, capture_output=True, timeout=10)
assert control.returncode == 0, control.stderr
control_report = json.loads((root / 'work/report.json').read_text())
for field in ['read', 'write', 'outside_write', 'symlink_read', 'empty_environment_read']:
assert control_report[field] == 'allowed', (field, control_report)
(root / 'work/alias').unlink()
(root / 'outside/escape').unlink()
(root / 'protected/keep').write_text('original')
print('PASS negative control: probes succeed without confinement')
result = subprocess.run(args, env=env, text=True, capture_output=True, timeout=90)
print(result.stderr)
if result.returncode:
raise SystemExit(result.returncode)
report = json.loads((root / 'work/report.json').read_text())
for _ in range(30):
try:
os.kill(report['descendant_pid'], 0)
except ProcessLookupError:
break
time.sleep(0.1)
else:
raise AssertionError('Buzz shutdown stranded a descendant')
print('PASS descendant stopped with Buzz')
assert report['allowed_write'] == 'allowed', report
assert report['ambient_secret'] == 'absent', report
for field in ['read', 'write', 'outside_write', 'hardlink', 'symlink_read', 'policy_tamper', 'empty_environment_read', 'socket']:
assert report[field] in ['EPERM', 'EACCES'], (field, report)
print(f'PASS {field.replace("_", " ")} blocked')
assert (root / 'protected/keep').read_text() == 'original'
assert 'Protected' in result.stderr
print('PASS ordinary workspace work completed')
print('PASS ungranted environment secret removed')
# A second process exercises the same frozen-input launch path afresh.
second = subprocess.run(args[:-2], input='/restart\nRun the probes.\n/quit\n', env=env, text=True, capture_output=True, timeout=90)
assert second.returncode == 0 and 'Restarted under the same immutable policy' in second.stderr, second.stderr
print('PASS restart and reinitialize')
# Fail closed before even launching an adapter.
invalid = dict(policy, schema_version=999)
(root / 'invalid.json').write_text(json.dumps(invalid))
invalid_args = list(args)
invalid_args[invalid_args.index('--policy')+1] = str(root / 'invalid.json')
failed = subprocess.run(invalid_args, env=env, text=True, capture_output=True, timeout=20)
assert failed.returncode != 0 and 'unsupported security schema' in failed.stderr
print('PASS unsupported policy refused')
# A separate executable directory must not fall back to PATH/Homebrew.
with tempfile.TemporaryDirectory(prefix='buzz-missing-engine-') as isolated:
isolated = Path(isolated)
harness = isolated / 'buzz-acp'
shutil.copy2(REPO / 'target/debug/buzz-acp', harness)
isolated_args = [str(harness), *args[1:]]
absent = subprocess.run(isolated_args, env=env, text=True, capture_output=True, timeout=20)
assert absent.returncode != 0 and 'bundled buzz-sandpit is missing' in absent.stderr, absent.stderr
print('PASS missing engine refused without fallback')
engine = isolated / 'buzz-sandpit'
engine.write_text('#!/bin/sh\nprintf incompatible\n')
engine.chmod(0o700)
incompatible = subprocess.run(isolated_args, env=env, text=True, capture_output=True, timeout=20)
assert incompatible.returncode != 0 and 'incompatible bundled security engine' in incompatible.stderr, incompatible.stderr
print('PASS incompatible engine refused without fallback')
print(f'\nTry/edit the policy: {root / "policy.json"}')
print(f'Work product: {root / "work/result.txt"}')
print(f'Probe report: {root / "work/report.json"}')

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Repeatable local app bundle, with isolated app, keyring, and OAuth identities.
set -euo pipefail
cd "$(dirname "$0")/.."
source bin/activate-hermit
just sandpit-poc-build
cargo build -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
TARGET_TRIPLE=$(rustc -vV | sed -n 's|host: ||p')
for executable in buzz-acp buzz-agent buzz-dev-mcp buzz git-credential-nostr; do
cp "target/debug/$executable" "desktop/src-tauri/binaries/$executable-$TARGET_TRIPLE"
done
DEMO_CONFIG=$(mktemp /tmp/buzz-sandpit-bundle.XXXXXX)
trap 'rm -f "$DEMO_CONFIG"' EXIT
node desktop/scripts/demo-build-config.mjs 'Sandpit Demo' "$DEMO_CONFIG" 2026091500000001
BUZZ_BUILD_DEMO_SLUG=sandpit-demo-2026091500000001 pnpm --dir desktop tauri build --debug --bundles app --config "$DEMO_CONFIG"
DEMO_APP='desktop/src-tauri/target/debug/bundle/macos/Buzz Sandpit Demo.app'
cp sandpit/target/release/sandpit "$DEMO_APP/Contents/MacOS/buzz-sandpit"
codesign --force --deep --sign - "$DEMO_APP"
printf 'Demo app: %s/%s\n' "$PWD" "$DEMO_APP"

19
scripts/sandpit-desktop-demo.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Local development preview with its own Desktop identity and keyring namespace.
set -euo pipefail
cd "$(dirname "$0")/.."
source bin/activate-hermit
cargo build --manifest-path sandpit/Cargo.toml --release -p sandpit-dylib
cargo build --manifest-path sandpit/Cargo.toml --release --bin sandpit
cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
cp sandpit/target/release/sandpit target/debug/buzz-sandpit
TARGET_TRIPLE=$(rustc -vV | sed -n 's|host: ||p')
for executable in buzz-acp buzz-agent buzz-dev-mcp buzz git-credential-nostr; do
cp "target/debug/$executable" "desktop/src-tauri/binaries/$executable-$TARGET_TRIPLE"
done
# Development lookup uses workspace binaries, including the adjacent engine.
export BUZZ_DEV_KEYRING_SERVICE=buzz-desktop-dev.sandpit
export BUZZ_RELAY_URL="${BUZZ_RELAY_URL:-ws://localhost:3000}"
unset BUZZ_PRIVATE_KEY BUZZ_SHARE_IDENTITY BUZZ_ACP_SECURITY_POLICY BUZZ_ACP_SECURITY_STATUS
cd desktop
exec ../bin/pnpm exec tauri dev --config '{"identifier":"xyz.block.buzz.app.dev.sandpit","productName":"Buzz Sandpit Demo","build":{"devUrl":"http://localhost:1437","beforeDevCommand":"exec ./node_modules/.bin/vite --port 1437 --strictPort"}}' "$@"

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Try a real native Buzz agent with existing Databricks sign-in. No relay required."""
import argparse
import json
import os
from pathlib import Path
import shlex
import subprocess
import tempfile
from urllib.parse import urlparse
repo = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--host', default=os.environ.get('DATABRICKS_HOST'))
parser.add_argument('--model', default='databricks-gpt-5-5-pro')
parser.add_argument('--interactive', action='store_true')
args = parser.parse_args()
if not args.host:
# Read only the workspace setting; auth remains in the native agent's cache.
goose_config = Path.home() / '.config/goose/config.yaml'
if goose_config.exists():
for line in goose_config.read_text().splitlines():
if line.startswith('DATABRICKS_HOST:'):
args.host = line.split(':', 1)[1].strip().strip('"').strip("'")
if not args.host:
parser.error('provide --host with your Databricks workspace URL')
host = urlparse(args.host if '://' in args.host else 'https://' + args.host).hostname
if not host:
parser.error('invalid workspace URL')
root = Path(tempfile.mkdtemp(prefix='buzz-sandpit-live-', dir='/tmp')).resolve()
for name in ['protected', 'work', 'outside']:
(root / name).mkdir()
(root / 'protected/secret').write_text('SYNTHETIC_SECRET')
(root / 'protected/keep').write_text('original')
(root / 'work/secret-link').symlink_to(root / 'protected/secret')
(root / 'work/AGENTS.md').write_text('Only use these disposable fixtures. Do not inspect other directories.\n')
node = subprocess.check_output([str(repo / 'bin/node'), '-p', 'process.execPath'], text=True).strip()
probe = repo / 'scripts/fixtures/sandpit-acp-probe.cjs'
names = ['BUZZ_AGENT_PROVIDER', 'DATABRICKS_HOST', 'DATABRICKS_MODEL',
'BUZZ_AGENT_MAX_ROUNDS', 'BUZZ_AGENT_NO_HINTS', 'BUZZ_AGENT_MAX_OUTPUT_TOKENS',
'BUZZ_SANDBOX_FIXTURE']
policy = {'schema_version': 1, 'writable_roots': [str(root / 'work')],
'denied_reads': [str(root / 'protected')], 'denied_writes': [str(root / 'protected')],
'network': {'mode': 'allowlist', 'destinations': [host]}, 'environment': names}
(root / 'policy.json').write_text(json.dumps(policy, indent=2))
env = os.environ.copy()
env.update(BUZZ_AGENT_PROVIDER='databricks_v2', DATABRICKS_HOST=args.host,
DATABRICKS_MODEL=args.model, BUZZ_AGENT_MAX_ROUNDS='5', BUZZ_AGENT_NO_HINTS='1',
BUZZ_AGENT_MAX_OUTPUT_TOKENS='2048', BUZZ_SANDBOX_FIXTURE=str(root))
command = [str(repo / 'target/debug/buzz-acp'), 'sandbox-chat', '--policy', str(root / 'policy.json'),
'--agent-command', str(repo / 'target/debug/buzz-agent'),
'--mcp-command', str(repo / 'target/debug/buzz-dev-mcp'), '--workspace', str(root / 'work')]
if not args.interactive:
task = ('Use your shell tool once to create hello.txt containing HELLO_FROM_BUZZ, then run exactly: '
+ shlex.join([node, str(probe), '--probe', '--root', str(root)])
+ '. This is an authorized test of disposable synthetic files. The probe records allowed work and expected denials in report.json. '
'Do not modify the probe, retry denials, or inspect other directories. Stop after executing this tool.')
command.extend(['--prompt', task])
print(f'Workspace: {root / "work"}\nPolicy: {root / "policy.json"}', flush=True)
print('Writes: disposable workspace only. Network: configured model host only. OAuth cache: read-only.', flush=True)
print('Ctrl-C stops the session. Model calls use your existing Buzz Databricks sign-in.', flush=True)
result = subprocess.run(command, env=env)
if result.returncode:
raise SystemExit(result.returncode)
if not args.interactive:
report = json.loads((root / 'work/report.json').read_text())
assert (root / 'work/hello.txt').read_text().strip() == 'HELLO_FROM_BUZZ'
assert (root / 'protected/keep').read_text() == 'original'
for field in ['read', 'write', 'outside_write', 'hardlink', 'symlink_read', 'policy_tamper', 'empty_environment_read', 'socket']:
assert report[field] in ['EACCES', 'EPERM'], (field, report)
assert report['allowed_write'] == 'allowed'
print(f'PASS real model → Buzz ACP → agent → MCP → shell, with enforced denials.\nReport: {root / "work/report.json"}')

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Real native agent + MCP + shell under Sandpit, with a local scripted model.
No real model, credentials, relay, or production user data are used. This proves
native tool plumbing and confinement; it is not the live UI conversation test.
"""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import shlex
import subprocess
import tempfile
import threading
repo = Path(__file__).resolve().parents[1]
root = Path(tempfile.mkdtemp(prefix='buzz-native-security-', dir='/tmp')).resolve()
for name in ['home', 'work', 'protected', 'outside', 'control']:
(root / name).mkdir()
(root / 'protected/secret').write_text('synthetic')
(root / 'protected/keep').write_text('original')
(root / 'work/secret-link').symlink_to(root / 'protected/secret')
node = subprocess.check_output([str(repo / 'bin/node'), '-p', 'process.execPath'], text=True).strip()
probe_command = shlex.join([node, str(repo / 'scripts/fixtures/sandpit-acp-probe.cjs'), '--probe', '--root', str(root)])
requests = []
class Model(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def do_POST(self):
request = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
requests.append(request)
if len(requests) == 1:
names = [tool['function']['name'] for tool in request['tools']]
shell = next(name for name in names if name.endswith('__shell') or name == 'shell')
message = {'role': 'assistant', 'content': None, 'tool_calls': [{'id': 'probe', 'type': 'function', 'function': {'name': shell, 'arguments': json.dumps({'command': probe_command, 'workdir': str(root / 'work')})}}]}
finish = 'tool_calls'
else:
message = {'role': 'assistant', 'content': 'Scripted model: probe complete.'}
finish = 'stop'
body = json.dumps({'id': 'local-scripted-model', 'object': 'chat.completion', 'model': 'scripted-test', 'choices': [{'index': 0, 'message': message, 'finish_reason': finish}]}).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
server = ThreadingHTTPServer(('127.0.0.1', 0), Model)
threading.Thread(target=server.serve_forever, daemon=True).start()
values = {'BUZZ_AGENT_PROVIDER': 'openai', 'OPENAI_COMPAT_API_KEY': 'synthetic-test', 'OPENAI_COMPAT_MODEL': 'scripted-test', 'OPENAI_COMPAT_BASE_URL': f'http://127.0.0.1:{server.server_port}', 'BUZZ_AGENT_MAX_ROUNDS': '3', 'BUZZ_AGENT_NO_HINTS': '1', 'BUZZ_SANDBOX_FIXTURE': str(root)}
policy = {'schema_version': 1, 'writable_roots': [str(root / 'work')], 'denied_reads': [str(root / 'protected')], 'denied_writes': [str(root / 'protected')], 'network': {'mode': 'allowlist', 'destinations': [f'localhost:{server.server_port}']}, 'environment': list(values)}
source = root / 'control/policy.json'
source.write_text(json.dumps(policy))
status = root / 'control/protected'
env = {'HOME': str(root / 'home'), 'PATH': os.environ['PATH'], 'TMPDIR': str(root), 'BUZZ_ACP_SECURITY_STATUS': str(status), **values}
try:
result = subprocess.run([str(repo / 'target/debug/buzz-acp'), 'sandbox-chat', '--policy', str(source), '--agent-command', str(repo / 'target/debug/buzz-agent'), '--mcp-command', str(repo / 'target/debug/buzz-dev-mcp'), '--workspace', str(root / 'work'), '--prompt', 'Execute the disposable probe once.'], env=env, text=True, capture_output=True, timeout=60)
(root / 'transcript.txt').write_text(result.stdout + result.stderr)
(root / 'model-requests.json').write_text(json.dumps(requests, indent=2))
assert result.returncode == 0, result.stderr
report = json.loads((root / 'work/report.json').read_text())
assert len(requests) == 2, len(requests)
assert len(status.read_text()) == 64
assert report['allowed_write'] == 'allowed'
for key in ['read', 'write', 'outside_write', 'hardlink', 'symlink_read', 'policy_tamper', 'empty_environment_read', 'socket']:
assert report[key] in ['EACCES', 'EPERM'], (key, report)
assert (root / 'protected/keep').read_text() == 'original'
print(f'PASS native Buzz agent → local scripted model → MCP → real shell, with eight enforced denials.\nArtifacts: {root}')
finally:
server.shutdown()
server.server_close()