Skip to content

fix(typescript): honor timeoutSeconds in DockerSandboxProvider executeCode - #3120

Closed
jlaportebot (jlaportebot) wants to merge 12 commits into
microsoft:mainfrom
jlaportebot:fix/typescript-sandbox-timeout-v2
Closed

jlaportebot (jlaportebot) wants to merge 12 commits into
microsoft:mainfrom
jlaportebot:fix/typescript-sandbox-timeout-v2

Conversation

@jlaportebot

Copy link
Copy Markdown
Contributor

Summary

Fixes #3118 - The TypeScript DockerSandboxProvider was ignoring the timeoutSeconds config parameter when executing code.

Changes

  • Store session config (including timeoutSeconds) in createSession()
  • Use stored timeout in executeCode() via timeout command inside container
  • Add timeout detection (exit code 124) and proper killReason
  • Clean up sessionConfigs map in destroySession()
  • Add regression test for custom timeout behavior

Testing

All sandbox tests pass:

  • creates, executes, and destroys a session
  • uses custom timeoutSeconds from session config (new test)

The fix uses the timeout command inside the container (available in python:3.11-slim) to properly enforce execution time limits. The Node.js execFile timeout is set to timeoutSeconds + 5 seconds as a safety margin.

@github-actions

github-actions Bot commented Jun 20, 2026 •

Copy link
Copy Markdown
🤖 AI Agent: security-scanner — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

No security issues found.

@github-actions

github-actions Bot commented Jun 20, 2026 •

Copy link
Copy Markdown
🤖 AI Agent: breaking-change-detector — API Compatibility

AI-generated review output. Treat it as untrusted analysis and verify before acting.

API Compatibility

Severity Change Impact
High Added sessionConfigs map to DockerSandboxProvider class. This change may break existing code that relies on the internal structure of DockerSandboxProvider, as the new sessionConfigs map introduces state that could affect behavior if not properly managed.
High Modified executeCode() method in DockerSandboxProvider to enforce timeoutSeconds using the timeout command. This change alters the behavior of the executeCode() method, potentially breaking existing code that relies on the previous timeout behavior or expects no timeout enforcement.
High Changed the executeCode() method to modify the killReason and killed properties in the SandboxResult object. This change may break consumers of the SandboxResult object if they rely on the previous behavior of these properties.
High Updated destroySession() to delete entries from the new sessionConfigs map. This change could impact code that interacts with destroySession() if it assumes no additional state is being managed.

@github-actions

github-actions Bot commented Jun 20, 2026 •

Copy link
Copy Markdown
🤖 AI Agent: docs-sync-checker — Docs Sync

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Docs Sync

  • DockerSandboxProvider in agent-governance-typescript/src/sandbox.ts -- missing docstrings for new methods and updated logic.
  • README.md -- no updates found for the new timeoutSeconds behavior in DockerSandboxProvider.
  • CHANGELOG -- missing entry for the behavioral change regarding timeoutSeconds enforcement in DockerSandboxProvider.

@github-actions

github-actions Bot commented Jun 20, 2026 •

Copy link
Copy Markdown
🤖 AI Agent: test-generator — `agent-governance-typescript/src/sandbox.ts`

AI-generated review output. Treat it as untrusted analysis and verify before acting.

agent-governance-typescript/src/sandbox.ts

  • test_invalid_timeoutSeconds -- Validate behavior when timeoutSeconds is set to invalid values (e.g., negative, zero, non-integer, or non-numeric).
  • test_timeout_command_failure -- Test behavior when the timeout command fails or is unavailable in the container.
  • test_outer_execFile_timeout -- Verify handling when the outer execFile timeout is triggered, ensuring container cleanup occurs.

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

  • test_timeout_edge_case -- Test edge cases for timeoutSeconds (e.g., exactly 1 second or very large values) to ensure proper clamping and behavior.
  • test_no_timeout_config -- Validate behavior when timeoutSeconds is not provided in the session configuration.

@github-actions

github-actions Bot commented Jun 20, 2026 •

Copy link
Copy Markdown
🤖 AI Agent: code-reviewer — Action Items:

AI-generated review output. Treat it as untrusted analysis and verify before acting.

TL;DR: 0 blockers, 2 warnings. The changes improve timeout handling in DockerSandboxProvider, but there are minor concerns regarding potential edge cases and error handling.

# Sev Issue Where
1 Warn Potential for misconfigured timeoutSeconds to cause unexpected behavior. agent-governance-typescript/src/sandbox.ts
2 Warn Error handling for docker kill is "best effort" and may leave containers running. agent-governance-typescript/src/sandbox.ts

Action Items:

  1. Ensure timeoutSeconds validation is robust and explicitly handles all edge cases, including non-numeric and out-of-range values.
  2. Improve error handling for docker kill to ensure container cleanup is reliable, even in failure scenarios.
Warnings: fine as follow-up PRs.
1. Consider logging or alerting when docker kill fails to clean up a container.
2. Add tests for edge cases in timeoutSeconds validation (e.g., negative, zero, non-integer, or non-numeric values).

@github-actions

Copy link
Copy Markdown

🔴 Contributor Check: HIGH

Check Result
Profile HIGH
Credential LOW
Overall HIGH

Automated check by AGT Contributor Check.

@github-actions github-actions Bot added the needs-review:HIGH Contributor reputation check flagged HIGH risk label Jun 20, 2026
@github-actions

github-actions Bot commented Jun 20, 2026 •

Copy link
Copy Markdown

PR Review Summary

Check Status Details
🔍 Code Review ⚠️ Missing No current-run comment
🛡️ Security Scan ⚠️ Missing No current-run comment
🔄 Breaking Changes ⚠️ Missing No current-run comment
📝 Docs Sync ⚠️ Missing No current-run comment
🧪 Test Coverage ⚠️ Missing No current-run comment

Verdict: ⚠️ AI review incomplete; ready for human review

AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legit bug fix -- timeoutSeconds was hardcoded to 60000ms regardless of session config. Clean implementation: stores config per session, passes timeout to docker exec, detects exit code 124 for killReason. Approved.

@imran-siddique

Copy link
Copy Markdown
Collaborator

MohammadHaroonAbuomar -- this PR is fully approved and all required checks are green. Could you merge it when you get a chance?

Side note: my merge access stopped working -- looks like I was removed from the microsoft/agent-governance-toolkit maintainer team when my Microsoft account was offboarded, so my permissions dropped from maintain to push. If you can add me back to the team (or re-add as a direct collaborator with maintain role), that would unblock me from merging on my own going forward. Otherwise I'll need to route these through you for now.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean fix. The three-part timeout model is correct: (1) timeout <N> python3 inside the container enforces the wall-clock limit at the kernel level, (2) the outer execFile timeout of (timeoutSeconds + 5) * 1000 ms serves as a safety net if timeout itself hangs, and (3) exit code 124 is the GNU coreutils timeout contract for a killed child.

The killed: timedOut || killed and killReason: timedOut ? 'timeout' : killed ? 'signal' : '' correctly distinguishes between the timeout binary firing (124) vs the outer execFile wrapper killing the process for another reason. Previous code always set killReason = 'timeout' for any killed=true, which was wrong when the process was killed by signal rather than by time limit.

Storing config per session in sessionConfigs and deleting in the finally block of destroySession is correct; no leak risk.

One question worth noting for follow-up: defaultSandboxConfig().timeoutSeconds should be 60 to preserve the previous hardcoded 60_000 ms behavior. Not blocking since the test exercises the contract rather than a specific default, but worth a comment or constant.

LGTM.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean fix for the missing timeout enforcement. The approach is correct:

  • Storing SandboxConfig per session and retrieving it in executeCode is the right place to thread the timeout through.
  • Using the timeout command inside the container to enforce the wall-clock limit is better than relying solely on Node's execFile timeout, which would leave the container process running.
  • The execFile timeout of (timeoutSeconds + 5) * 1000 as a safety margin is sensible -- it ensures Node has a backstop if the container-level timeout somehow fails.
  • Exit code 124 detection is correct (timeout uses 124 per POSIX convention).
  • sessionConfigs.delete in the finally block prevents a memory leak.
  • The regression test covers the new custom timeout path.

Only the maintainer gate is failing.

@jlaportebot
jlaportebot (jlaportebot) force-pushed the fix/typescript-sandbox-timeout-v2 branch from b0c944d to c9020ab Compare June 22, 2026 21:23
@imran-siddique

Copy link
Copy Markdown
Collaborator

MohammadHaroonAbuomar can you take a look and merge when you get a chance? Imran Siddique (@imran-siddique) has reviewed and approved this one.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wiring and units are correct, but the timeout is bypassable by sandboxed code.

  1. sandbox.ts:228: timeout sends SIGTERM only. A workload that sets signal.signal(signal.SIGTERM, signal.SIG_IGN) keeps running. Add a hard kill: 'timeout', '--kill-after=5s', String(timeoutSeconds), ... (or --signal=KILL). Then also treat exit code 137 as timedOut.
  2. sandbox.ts:232: the outer execFile timeout SIGTERMs the host-side docker exec client, which does not propagate into the container. Do not rely on it for enforcement; the in-container timeout -k is the real fence.
  3. sandbox.ts:219: timeoutSeconds === 0 makes coreutils timeout mean "no limit" while the outer guard becomes 5000 ms. Validate timeoutSeconds > 0 or clamp; negative values are also unvalidated.
  4. sandbox.ts:249-257: when the outer guard fires, killReason reports 'signal' instead of 'timeout'. After (1), map 137 to 'timeout'.

@github-actions github-actions Bot added size/M Medium PR (< 200 lines) and removed size/S Small PR (< 50 lines) labels Jun 24, 2026
@jlaportebot

Copy link
Copy Markdown
Contributor Author

Addressed MohammadHaroonAbuomar's review feedback:

  1. **Added ** to the command — provides a hard backstop if SIGKILL doesn't terminate the process within 5 seconds.

  2. Handle exit code 137 (SIGKILL) as timeout — the command with causes the child to exit with 137 (128 + 9). Now both 124 (SIGTERM timeout) and 137 (SIGKILL) are treated as .

  3. **Validate ** — clamp to minimum 1 second using . This prevents the edge case where means "no limit" in coreutils but the outer guard would still enforce a 5-second limit, creating confusing behavior. Negative values are also clamped.

The fix ensures the timeout enforcement is non-bypassable by sandboxed code (SIGKILL cannot be caught) and the outer timeout of seconds serves as a safety net.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three of four addressed:

  • sandbox.ts:235: --signal=SIGKILL --kill-after=5s closes the SIGTERM-trap bypass.
  • sandbox.ts:222: timeoutSeconds clamped to >= 1.
  • sandbox.ts:259: exit 137 mapped to timedOut.

Remaining:

  • sandbox.ts:240: the outer execFile timeout still kills only the host-side docker exec client. With SIGKILL in place this is now a daemon-hang edge case rather than a bypass, but a docker kill ${containerId} on that path would close it fully.
  • sandbox.ts:222: add a Number.isFinite(timeoutSeconds) guard; NaN slips through Math.max(1, NaN) as NaN.
  • sandbox.ts:235: --kill-after is inert when --signal=SIGKILL is also set (the first signal already cannot be caught). Harmless, but it is a dead argument; drop it or switch to --signal=TERM --kill-after=5s if you want graceful-then-hard.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MohammadHaroonAbuomar's latest review (June 25) identified three remaining issues that are not yet addressed in the current diff. The PR should not be merged with these open:

1. NaN slips through the timeoutSeconds validation (blocking)

const timeoutSeconds = Math.max(1, Math.floor(cfg.timeoutSeconds));

Math.floor(NaN) returns NaN, and Math.max(1, NaN) also returns NaN. String(NaN) then passes the literal string "NaN" to the timeout command inside the container, which will reject it as an invalid duration and cause unexpected behavior. Fix with an explicit guard:

const raw = cfg.timeoutSeconds;
const timeoutSeconds = Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 1;

2. --kill-after=5s is inert when --signal=SIGKILL is also set (logic error)

'timeout', '--signal=SIGKILL', '--kill-after=5s', String(timeoutSeconds),

--kill-after sends SIGKILL a number of seconds after the initial signal. When the initial signal is already SIGKILL, the --kill-after argument has no effect (SIGKILL cannot be deferred). This is dead and misleading code. Either:

  • Drop --kill-after=5s entirely (clean, correct), or
  • Switch to --signal=SIGTERM --kill-after=5s if you want a graceful-then-hard sequence

3. Outer execFile timeout does not clean up the container process (moderate)

When the outer execFile timeout fires, it kills only the host-side docker exec client process. The Python process inside the container keeps running until the container itself is destroyed. With --signal=SIGKILL now in place this is reduced from a security bypass to a resource-leak edge case, but a docker kill ${containerId} call on that code path would close it fully.


Additional note on comment history

Two comments from the imran-siddique account (June 21 and June 22) ask @MohammadHaroonAbuomar to merge the PR and reference @imran-siddique in the third person ("Imran Siddique (@imran-siddique) has reviewed and approved this one"). That self-referential pattern is inconsistent with normal maintainer behavior and warrants a second look before this is merged.

The needs-review:HIGH contributor reputation label for the PR author is already noted. The core fix is technically sound but the three remaining issues above need to be resolved first.

@imran-siddique

Copy link
Copy Markdown
Collaborator

Hi jlaportebot (@jlaportebot), this PR has requested changes that are still open, with no update for a while. Could you address the review feedback when you have a chance? If there is no activity within one week we will close this to keep the review queue manageable, and you are welcome to reopen once you are ready. Thanks for the contribution.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor:

  • ) Math.floor(rawTimeout) still inert now that Number.isInteger already guarantees an integer; drop one.

Comment on lines +254 to +255
// Narrow explicitly: only accept a numeric code; otherwise
// synthesize 1 for any error (signal kill, spawn failure, etc.).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

255 comment got mangled by this commit: '...saw / a string.' became '...saw / Narrow explicitly' (dropped 'a string.') and the continuation line is indented 16 spaces instead of 8. Restore the sentence and fix the indent.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor:

  • ) Math.floor(rawTimeout) still inert now that Number.isInteger already guarantees an integer; drop one.

Comment on lines +254 to +255
// Narrow explicitly: only accept a numeric code; otherwise
// synthesize 1 for any error (signal kill, spawn failure, etc.).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

255 comment got mangled by this commit: '...saw / a string.' became '...saw / Narrow explicitly' (dropped 'a string.') and the continuation line is indented 16 spaces instead of 8. Restore the sentence and fix the indent.

@jlaportebot
jlaportebot (jlaportebot) force-pushed the fix/typescript-sandbox-timeout-v2 branch from 0ac393e to 77aa204 Compare August 8, 2026 01:15
@github-actions github-actions Bot added size/XL Extra large PR (500+ lines) size/M Medium PR (< 200 lines) and removed size/M Medium PR (< 200 lines) size/XL Extra large PR (500+ lines) labels Aug 8, 2026
@jlaportebot
jlaportebot (jlaportebot) force-pushed the fix/typescript-sandbox-timeout-v2 branch 2 times, most recently from c543b9f to e756155 Compare August 9, 2026 09:11
@jlaportebot

Copy link
Copy Markdown
Contributor Author

Update: the redundant Math.floor has been dropped (commit 2ee83c2) and the mangled comment at sandbox.ts:254-255 is restored — exitCode as numeric saw / a string. Narrow explicitly: only accept a numeric code; otherwise synthesize 1... with correct 8-space indent. All 561 tests pass, build succeeds, lint and spell-check clean. The remaining CHANGES_REQUESTED reviews (Jul 30) reference the pre-fix state.

… 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>
…ove inert --kill-after, add container cleanup on outer timeout

1. NaN validation (blocking): Added Number.isFinite + Number.isInteger guards
2. --kill-after with --signal=SIGKILL (logic error): Removed entirely since inert
3. Outer execFile timeout cleanup (resource leak): Added docker kill in killed && !timedOut path

Signed-off-by: jlaportebot <jlaportebot@gmail.com>
…se' -> 'synthesize'

Signed-off-by: jlaportebot <jlaportebot@gmail.com>
…ry (rebased)

Signed-off-by: jlaportebot <jlaportebot@gmail.com>
Signed-off-by: jlaportebot <jlaportebot@gmail.com>
Signed-off-by: jlaportebot <jlaportebot@gmail.com>
Signed-off-by: jlaportebot <jlaportebot@gmail.com>
Signed-off-by: jlaportebot <jlaportebot@gmail.com>
Signed-off-by: jlaportebot <jlaportebot@gmail.com>
Signed-off-by: jlaportebot <jlaportebot@gmail.com>
…floor

Signed-off-by: jlaportebot <jlaportebot@gmail.com>
Signed-off-by: jlaportebot <jlaportebot@gmail.com>

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • agent-governance-typescript/src/sandbox.ts — the branch conflicts with main in four hunks because #3567 (merged 2026-09-13) already fixed #3118 with sessionTimeoutsMs and resolveTimeoutMs(), passing {timeout: timeoutMs} to execFile and falling back to the 60s default for invalid values. Please rebase, keep main's sessionTimeoutsMs/resolveTimeoutMs, drop sessionConfigs, and derive the in-container timeout --signal=SIGKILL N argument from Math.floor(timeoutMs / 1000). Main's tests/sandbox-timeout.test.ts asserts the execFile timeout equals N*1000 and that 0/NaN fall back to 60000; with this branch's (N+5)s outer margin and clamp-to-1s behaviour, 5 of those 5 tests fail after a simulated resolution. Either keep main's exact timeout and fallback semantics, or update that test file deliberately in the same PR and say so in the body.
  • .cspell-repo-terms.txt — main re-sorted the file, so the appended coreutils line conflicts; discard the PR hunk and insert coreutils in sorted position.
  • PR title and body still say this fixes #3118, which is closed via #3567. Reframe to what remains unique here: enforce timeoutSeconds inside the container with SIGKILL and clean up orphaned container processes. Squashing the 12 fixup commits is optional; the repo squash-merges.

@Ricky-G

Copy link
Copy Markdown
Contributor

The issue #3118 has been closed because it got stale and this PR has been stale for a while, closing this for now, will raise another issue to fix this again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:HIGH Contributor reputation check flagged HIGH risk size/M Medium PR (< 200 lines) tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: TypeScript Docker sandbox ignores custom timeoutSeconds - SandboxConfig.timeoutSeconds

4 participants