Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit 1a8dcbc

Browse files
committed
feat: add core Sandbox interface (base/local/shell/remote/tools)
1 parent 851b4e3 commit 1a8dcbc

38 files changed

Lines changed: 3600 additions & 3 deletions

strands-ts/eslint.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ function sdkRules(options) {
5353
globals: {
5454
console: 'readonly',
5555
process: 'readonly',
56+
setTimeout: 'readonly',
57+
clearTimeout: 'readonly',
58+
setInterval: 'readonly',
59+
clearInterval: 'readonly',
60+
atob: 'readonly',
61+
btoa: 'readonly',
5662
},
5763
},
5864
plugins: {

strands-ts/src/agent/agent.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
type LocalAgent,
99
type localAgentSymbol,
1010
} from '../types/agent.js'
11+
import type { Sandbox } from '../sandbox/base.js'
12+
import { LocalSandbox } from '../sandbox/local.js'
1113
import { BedrockModel } from '../models/bedrock.js'
1214
import {
1315
contentBlockFromData,
@@ -194,6 +196,12 @@ export type AgentConfig = {
194196
* Defaults to `'concurrent'`. See {@link ToolExecutorStrategy} for details.
195197
*/
196198
toolExecutor?: ToolExecutorStrategy
199+
/**
200+
* Sandbox for tool code execution and filesystem access.
201+
* Defaults to {@link LocalSandbox} (native local execution).
202+
* Pass {@link NullSandbox} to explicitly disable sandbox functionality.
203+
*/
204+
sandbox?: Sandbox
197205
}
198206

199207
/** Default name assigned to agents when none is provided. */
@@ -233,6 +241,11 @@ export class Agent implements LocalAgent, InvokableAgent {
233241
*/
234242
public model: Model
235243

244+
/**
245+
* Sandbox for tool code execution and filesystem access.
246+
*/
247+
public readonly sandbox: Sandbox
248+
236249
/**
237250
* The system prompt to pass to the model provider.
238251
*/
@@ -351,6 +364,8 @@ export class Agent implements LocalAgent, InvokableAgent {
351364

352365
this._toolExecutor = config?.toolExecutor ?? 'concurrent'
353366

367+
this.sandbox = config?.sandbox ?? new LocalSandbox()
368+
354369
this._initialized = false
355370
}
356371

strands-ts/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,22 @@ export { AgentTrace } from './telemetry/tracer.js'
257257
// Local Metrics
258258
export { AgentMetrics } from './telemetry/meter.js'
259259

260+
// Sandbox
261+
export { Sandbox, type ExecuteOptions } from './sandbox/base.js'
262+
export { LocalSandbox, type LocalSandboxOptions } from './sandbox/local.js'
263+
export { ShellSandbox } from './sandbox/shell.js'
264+
export { RemoteSandbox, type RemoteSandboxOptions } from './sandbox/remote.js'
265+
export { DockerSandbox, type DockerSandboxOptions } from './sandbox/docker.js'
266+
export { NullSandbox } from './sandbox/null.js'
267+
export type {
268+
StreamType,
269+
StreamChunk,
270+
FileInfo,
271+
OutputFile,
272+
ExecutionResult,
273+
SandboxSnapshot,
274+
} from './sandbox/types.js'
275+
260276
// Multi-agent orchestration
261277
export { Graph } from './multiagent/index.js'
262278
export { Swarm } from './multiagent/index.js'
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import { LocalSandbox } from '../local.js'
3+
import { execSync } from 'child_process'
4+
5+
const TEST_DIR = '/tmp/strands-test-adversarial'
6+
7+
describe.skipIf(process.platform === 'win32')('Sandbox adversarial tests', () => {
8+
let sandbox: LocalSandbox
9+
10+
beforeEach(() => {
11+
execSync(`rm -rf ${TEST_DIR} && mkdir -p ${TEST_DIR}`)
12+
sandbox = new LocalSandbox({ workingDir: TEST_DIR })
13+
})
14+
15+
afterEach(() => {
16+
execSync(`rm -rf ${TEST_DIR}`)
17+
})
18+
19+
describe('path traversal', () => {
20+
it('LocalSandbox does NOT enforce path confinement (documented behavior)', async () => {
21+
await sandbox.writeText('/tmp/strands-test-adversarial-outside.txt', 'escaped')
22+
const text = await sandbox.readText('/tmp/strands-test-adversarial-outside.txt')
23+
expect(text).toBe('escaped')
24+
execSync('rm -f /tmp/strands-test-adversarial-outside.txt')
25+
})
26+
27+
it('relative path with .. resolves against workingDir', async () => {
28+
await sandbox.writeText('subdir/file.txt', 'inner')
29+
const text = await sandbox.readText('subdir/../subdir/file.txt')
30+
expect(text).toBe('inner')
31+
})
32+
})
33+
34+
describe('command injection', () => {
35+
it('language parameter rejects path traversal', async () => {
36+
await expect(sandbox.executeCode('x', '../../../bin/sh')).rejects.toThrow('unsafe characters')
37+
})
38+
39+
it('language parameter rejects shell metacharacters', async () => {
40+
await expect(sandbox.executeCode('x', 'python;rm -rf /')).rejects.toThrow('unsafe characters')
41+
})
42+
43+
it('language parameter rejects spaces', async () => {
44+
await expect(sandbox.executeCode('x', 'python -c')).rejects.toThrow('unsafe characters')
45+
})
46+
47+
it('language parameter allows valid interpreters', async () => {
48+
const result = await sandbox.executeCode('print("safe")', 'python3')
49+
expect(result.exitCode).toBe(0)
50+
})
51+
52+
it('language parameter allows dots and hyphens', async () => {
53+
const result = await sandbox.executeCode('x', 'fake-lang.99')
54+
expect(result.exitCode).toBe(127)
55+
})
56+
})
57+
58+
describe('binary and edge-case content', () => {
59+
it('handles null bytes in files', async () => {
60+
const content = new Uint8Array([0, 0, 0, 65, 0, 66, 0])
61+
await sandbox.write('nulls.bin', content)
62+
const read = await sandbox.read('nulls.bin')
63+
expect(Array.from(read)).toStrictEqual(Array.from(content))
64+
})
65+
66+
it('handles all 256 byte values', async () => {
67+
const content = new Uint8Array(256)
68+
for (let i = 0; i < 256; i++) content[i] = i
69+
await sandbox.write('all-bytes.bin', content)
70+
const read = await sandbox.read('all-bytes.bin')
71+
expect(Array.from(read)).toStrictEqual(Array.from(content))
72+
})
73+
74+
it('handles empty file', async () => {
75+
await sandbox.write('empty.txt', new Uint8Array(0))
76+
const read = await sandbox.read('empty.txt')
77+
expect(read.length).toBe(0)
78+
})
79+
80+
it('handles file with only newlines', async () => {
81+
await sandbox.writeText('newlines.txt', '\n\n\n')
82+
const text = await sandbox.readText('newlines.txt')
83+
expect(text).toBe('\n\n\n')
84+
})
85+
86+
it('handles unicode content', async () => {
87+
const content = '日本語テスト 🚀 émojis Ñ'
88+
await sandbox.writeText('unicode.txt', content)
89+
const text = await sandbox.readText('unicode.txt')
90+
expect(text).toBe(content)
91+
})
92+
93+
it('handles shell metacharacters in file content', async () => {
94+
const content = '$(rm -rf /) `whoami` && || ; | > < $HOME'
95+
await sandbox.writeText('meta.txt', content)
96+
const text = await sandbox.readText('meta.txt')
97+
expect(text).toBe(content)
98+
})
99+
})
100+
101+
describe('concurrent execution', () => {
102+
it('handles multiple concurrent commands', async () => {
103+
const results = await Promise.all([
104+
sandbox.execute('echo one'),
105+
sandbox.execute('echo two'),
106+
sandbox.execute('echo three'),
107+
])
108+
expect(results.map((r) => r.stdout.trim()).sort()).toStrictEqual(['one', 'three', 'two'])
109+
})
110+
111+
it('handles concurrent file writes to different files', async () => {
112+
await Promise.all([
113+
sandbox.writeText('a.txt', 'aaa'),
114+
sandbox.writeText('b.txt', 'bbb'),
115+
sandbox.writeText('c.txt', 'ccc'),
116+
])
117+
const [a, b, c] = await Promise.all([
118+
sandbox.readText('a.txt'),
119+
sandbox.readText('b.txt'),
120+
sandbox.readText('c.txt'),
121+
])
122+
expect(a).toBe('aaa')
123+
expect(b).toBe('bbb')
124+
expect(c).toBe('ccc')
125+
})
126+
127+
it('two sandbox instances with different workingDirs are isolated', async () => {
128+
const sandbox2 = new LocalSandbox({ workingDir: `${TEST_DIR}-2` })
129+
await sandbox.writeText('shared-name.txt', 'from sandbox 1')
130+
await sandbox2.writeText('shared-name.txt', 'from sandbox 2')
131+
132+
const text1 = await sandbox.readText('shared-name.txt')
133+
const text2 = await sandbox2.readText('shared-name.txt')
134+
135+
expect(text1).toBe('from sandbox 1')
136+
expect(text2).toBe('from sandbox 2')
137+
138+
execSync(`rm -rf ${TEST_DIR}-2`)
139+
})
140+
})
141+
142+
describe('timeout behavior', () => {
143+
it('kills process on timeout', async () => {
144+
const start = Date.now()
145+
await expect(sandbox.execute('sleep 60', { timeout: 0.2 })).rejects.toThrow('timed out')
146+
const elapsed = Date.now() - start
147+
expect(elapsed).toBeLessThan(2000)
148+
})
149+
150+
it('does not timeout fast commands', async () => {
151+
const result = await sandbox.execute('echo fast', { timeout: 5 })
152+
expect(result.exitCode).toBe(0)
153+
expect(result.stdout).toBe('fast\n')
154+
})
155+
})
156+
})

0 commit comments

Comments
 (0)