Skip to content

Commit bb31dcc

Browse files
committed
fix(sandbox): add SIGKILL enforcement, timeoutSeconds validation, and exit code 137 handling
- Store SandboxConfig per session and retrieve in executeCode - Use coreutils timeout with --signal=SIGKILL --kill-after=5s for in-container enforcement - Validate timeoutSeconds: clamp to >=1, reject NaN - Treat exit codes 124 (SIGTERM timeout) and 137 (SIGKILL) as timedOut - Map killReason: 'timeout' for timedOut, 'signal' for killed by other signal - Outer execFile timeout set to (timeoutSeconds + 5) * 1000 as safety net - Added regression test for custom timeoutSeconds Signed-off-by: jlaportebot <jlaportebot@gmail.com>
1 parent a09554a commit bb31dcc

2 files changed

Lines changed: 56 additions & 4 deletions

File tree

agent-governance-typescript/src/sandbox.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ function validateResourceName(value: string, label: string): void {
131131
export class DockerSandboxProvider implements SandboxProvider {
132132
private readonly image: string;
133133
private readonly containers = new Map<string, string>(); // sessionId -> containerId
134+
private readonly sessionConfigs = new Map<string, SandboxConfig>(); // sessionId -> config
134135

135136
constructor(image: string = 'python:3.11-slim') {
136137
this.image = image;
@@ -189,6 +190,7 @@ export class DockerSandboxProvider implements SandboxProvider {
189190
.trim();
190191

191192
this.containers.set(sessionId, containerId);
193+
this.sessionConfigs.set(sessionId, cfg);
192194

193195
return {
194196
agentId,
@@ -213,17 +215,31 @@ export class DockerSandboxProvider implements SandboxProvider {
213215
throw new Error(`No active session '${sessionId}' for agent '${agentId}'`);
214216
}
215217

218+
const cfg = this.sessionConfigs.get(sessionId) ?? defaultSandboxConfig();
219+
// Validate timeoutSeconds: must be positive. Zero/negative would mean "no limit"
220+
// in coreutils timeout, but our outer guard would still enforce a limit.
221+
// Clamp to 1 second minimum to avoid confusion.
222+
const timeoutSeconds = Number.isFinite(cfg.timeoutSeconds) && cfg.timeoutSeconds >= 1
223+
? Math.floor(cfg.timeoutSeconds)
224+
: 1;
225+
216226
const executionId = randomUUID();
217227
const startTime = Date.now();
218228

219229
return new Promise<ExecutionHandle>((resolve) => {
220230
const encoded = Buffer.from(code).toString('base64');
231+
// Use 'timeout' command with SIGKILL and kill-after to enforce execution time limit.
232+
// The default signal (SIGTERM) can be caught/ignored by sandboxed code,
233+
// allowing it to bypass the timeout. SIGKILL cannot be caught.
234+
// --kill-after=5s sends SIGKILL 5 seconds after the initial signal if the process
235+
// hasn't exited, providing a hard backstop.
221236
const execArgs = [
222-
'exec', containerId, 'python3', '-c',
237+
'exec', containerId, 'timeout', '--signal=SIGKILL', '--kill-after=5s', String(timeoutSeconds),
238+
'python3', '-c',
223239
`import base64; exec(base64.b64decode('${encoded}').decode())`,
224240
];
225241

226-
execFile('docker', execArgs, { timeout: 60_000 }, (error, stdout, stderr) => {
242+
execFile('docker', execArgs, { timeout: (timeoutSeconds + 5) * 1000 }, (error, stdout, stderr) => {
227243
const durationSeconds = (Date.now() - startTime) / 1000.0;
228244
// Node's ExecException.code can be: a numeric exit code (child exited
229245
// non-zero), `null` (child killed by a signal — `error.signal` is set
@@ -239,14 +255,19 @@ export class DockerSandboxProvider implements SandboxProvider {
239255
: 0;
240256
const killed = error !== null && 'killed' in error && (error as { killed: boolean }).killed;
241257

258+
// timeout command exits with 124 when the command times out (SIGTERM sent).
259+
// With --signal=SIGKILL, the child gets SIGKILL and exits with 137 (128 + 9).
260+
// Treat both 124 and 137 as timeout.
261+
const timedOut = exitCode === 124 || exitCode === 137;
262+
242263
const result: SandboxResult = {
243264
success: exitCode === 0,
244265
exitCode,
245266
stdout: stdout ?? '',
246267
stderr: stderr ?? '',
247268
durationSeconds,
248-
killed,
249-
killReason: killed ? 'timeout' : '',
269+
killed: timedOut || killed,
270+
killReason: timedOut ? 'timeout' : (killed ? 'signal' : ''),
250271
};
251272

252273
resolve({
@@ -275,6 +296,7 @@ export class DockerSandboxProvider implements SandboxProvider {
275296
});
276297
} finally {
277298
this.containers.delete(sessionId);
299+
this.sessionConfigs.delete(sessionId);
278300
}
279301
}
280302
}

agent-governance-typescript/tests/sandbox.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,34 @@ describe('DockerSandboxProvider lifecycle', () => {
133133
provider.executeCode('testAgent', 'nonexistent-id', 'print("hi")'),
134134
).rejects.toThrow(/No active session/);
135135
});
136+
137+
it('uses custom timeoutSeconds from session config', async () => {
138+
if (!dockerAvailable) {
139+
console.log('Skipping timeout test — Docker not available');
140+
return;
141+
}
142+
143+
// Create session with 2 second timeout
144+
const session = await provider.createSession('testAgent', {
145+
...defaultSandboxConfig(),
146+
timeoutSeconds: 2,
147+
});
148+
expect(session.status).toBe(SessionStatus.Ready);
149+
150+
try {
151+
// Execute code that sleeps for 3 seconds — should be killed by timeout
152+
const handle = await provider.executeCode(
153+
'testAgent',
154+
session.sessionId,
155+
'import time; time.sleep(3); print("done")',
156+
);
157+
expect(handle.status).toBe(ExecutionStatus.Failed);
158+
expect(handle.result).toBeDefined();
159+
expect(handle.result!.killed).toBe(true);
160+
expect(handle.result!.killReason).toBe('timeout');
161+
expect(handle.result!.durationSeconds).toBeLessThan(2.5); // Should timeout around 2s
162+
} finally {
163+
await provider.destroySession('testAgent', session.sessionId);
164+
}
165+
}, 15_000);
136166
});

0 commit comments

Comments
 (0)