Skip to content

LOU-K2: opt built-in I/O tools into the sandbox seam - #16

Merged
LinuxDevil merged 1 commit into
mainfrom
loushy/lou-k2
Sep 28, 2026
Merged

LinuxDevil merged 1 commit into
mainfrom
loushy/lou-k2

Conversation

@LinuxDevil

Copy link
Copy Markdown
Owner

Summary

The SDK's fail-closed sandboxing seam (ToolDescriptor.requiresSandbox/sandboxExecute, SandboxAdapter, executeToolWithSandboxGuard()) was fully implemented and wired into AgentExecutor/resume.ts, but no shipped built-in tool used it — requiresSandbox had zero matches under src/tools/built-in/. This PR opts the tools that do meaningful external I/O into the seam.

Which tools got requiresSandbox: true, and why

  • httpTool (http.ts) — makes arbitrary, model-chosen outbound HTTP requests. The single highest-risk built-in tool for a sandbox boundary to matter on.
  • GitHubTools (github.ts) — every in-scope tool (everything except the already-OUT_OF_SCOPE_TOOLS-gated ones, which throw before ever reaching the network and are deliberately left unflagged) calls the real GitHub API.
  • JiraTools (jira.ts) — every tool calls the caller-configured Jira instance.
  • slackTool (slack.ts) — POSTs to a webhook URL (env var or options).
  • Left unflagged: currentDateTool, dayNameTool — no I/O, so a sandbox boundary isn't a meaningful security property for them (per the ticket's own guidance not to force this everywhere).

How sandboxing is implemented

A new shared helper, src/tools/built-in/sandboxFetch.ts:

  • sandboxHttpFetch(sandbox, request) — encodes the request as JSON, runs it as a Node one-liner via sandbox.run('node', ['-e', script], ...), and decodes the subprocess's JSON stdout back into a real Response. This is the only way to route an outbound HTTP call through SandboxAdapter, since the adapter only exposes run()/writeFile(), not an injectable fetch. Under NoopSandbox this performs the exact same network request as calling fetch() in-process, just via a child node process.
  • withSandboxedFetch(sandbox, fn) — swaps globalThis.fetch for a sandbox-routed implementation only for the duration of fn(), restoring the original afterward (even on throw). Used by github.ts/jira.ts so their ~50 register*() methods (which all call the ambient global fetch) don't each need a hand-written sandboxed reimplementation.

Important design decision — execute() is left completely unchanged on every tool, not stubbed out. This codebase has real call sites that invoke descriptor.tool.execute() directly, entirely bypassing executeToolWithSandboxGuard() — e.g. examples/ops-pipeline/guardedPr.ts and examples/ops-pipeline/index.ts call slackTool.tool.execute!(...) / githubCreatePrTool.tool.execute!(...) directly, and github.test.ts's existing tests do the same. Stubbing execute() to fail closed (as the ticket's fallback suggestion allows "if the guard makes it unreachable") would have broken those real, already-shipped paths, so sandboxExecute() is added as a genuine parallel path used only by guard-routed callers (AgentExecutor, resume.ts); direct callers keep working exactly as before.

Per-file changes:

  • http.ts — refactored makeHttpRequest() into a transport-parameterized performHttpRequest() so the SSRF-checking/redirect-following logic (isBlockedHost, per-hop checks, maxRedirects) is shared between the direct (undici) transport and the new sandboxed transport (makeHttpRequestViaSandbox()), rather than duplicated.
  • github.ts/jira.ts — added/extended a register() override that wraps every fetch-performing tool's execute() with withSandboxedFetch() for sandboxExecute().
  • slack.ts — added postSlackAlertViaSandbox(), mirroring postSlackAlert() but posting through sandboxHttpFetch(). Also exported from tools/built-in/index.ts (it was missing from that barrel before this change).

Tests

Extended http.test.ts, github.test.ts, slack.test.ts, and added jira.test.ts (no prior test file existed for Jira). Each proves:

  • (a) behavior is unchanged under NoopSandbox — real local-server round-trips for http/slack/jira (whose target URL is caller-configurable); for github (whose base URL is hardcoded to api.github.com), execute() is asserted to still work unsandboxed exactly as before.
  • (b) a custom SandboxAdapter is genuinely invoked (sandbox.run('node', ...)) for the outbound call, with its canned response correctly parsed back into the tool's result.
  • (c) the tool fails closed (via the existing executeToolWithSandboxGuard contract) when sandboxExecute is missing.

Verification

  • npm run typecheck — clean.
  • npm run lint — 5 pre-existing errors in src/execution/__fixtures__/sample-repo/** (unrelated fixture files, predate this change), 369 pre-existing warnings; no new errors from touched files.
  • npx vitest run src/tools src/execution/sandbox-wiring.test.ts examples — 125 passed, 0 failed.
  • Full npx vitest run has 5 pre-existing failures unrelated to this change (missing optional peer dep ollama-ai-provider, and packages/create-loushy-agent tests needing a dist/ build) — confirmed identical on main by stashing this change and re-running.

Out of scope

Did not touch the published audit artifact, per the ticket.

🤖 Generated with Claude Code

The SDK's fail-closed sandboxing seam (ToolDescriptor.requiresSandbox /
sandboxExecute, SandboxAdapter, executeToolWithSandboxGuard) was fully
implemented and wired into AgentExecutor/resume.ts but unused by every
shipped built-in tool, so the feature was dead in practice.

Flags the tools that do meaningful external I/O - http (arbitrary
model-chosen outbound requests), github, jira (external API calls scoped
by config, still real network reach), and slack (webhook POST) - with
requiresSandbox: true and a real sandboxExecute() that routes the actual
outbound fetch through the configured SandboxAdapter via a new shared
helper (sandboxFetch.ts): it runs the request as a Node subprocess through
sandbox.run(), so a real isolation backend (e.g. SubprocessSandbox) gets a
genuine chance to mediate the call, while NoopSandbox reproduces the exact
same network request unsandboxed callers always got.

currentDate/dayName are left unflagged - they do no I/O, so a sandbox
boundary isn't a meaningful security property for them.

execute() is left untouched on every tool (not stubbed out): this
codebase has real call sites (examples/ops-pipeline's guardedPr.ts, and
existing tests) that invoke descriptor.tool.execute() directly, bypassing
executeToolWithSandboxGuard entirely - stubbing execute() to fail closed
would have broken them. sandboxExecute() is added as a genuine parallel
path used by guard-routed callers (AgentExecutor, resume.ts); execute()
remains real and directly callable exactly as before.

- github.ts/jira.ts: added a register() override that wraps every
  fetch-performing tool's execute() with withSandboxedFetch(), which
  swaps globalThis.fetch for the sandboxed implementation only for the
  duration of that call - avoiding a hand-written sandboxed
  reimplementation of all ~50 register*() methods. GitHub's
  already-out-of-scope tools (OUT_OF_SCOPE_TOOLS) are left unflagged
  since they throw before ever reaching the network.
- http.ts: refactored makeHttpRequest() into a transport-parameterized
  performHttpRequest() so the SSRF-checking/redirect-following logic is
  shared between the direct (undici) and sandboxed transports.
- slack.ts: added postSlackAlertViaSandbox(), mirroring postSlackAlert()
  but posting through sandboxHttpFetch().
- Extended http.test.ts/github.test.ts/slack.test.ts and added
  jira.test.ts (no prior test file existed) to prove: (a) behavior is
  unchanged under NoopSandbox, (b) a custom SandboxAdapter is genuinely
  invoked for the outbound call, (c) the tool fails closed per the
  existing guard contract when sandboxExecute is missing.
- Also exported slack.ts from tools/built-in/index.ts (previously
  missing from the barrel).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@LinuxDevil
LinuxDevil merged commit de2aece into main Sep 28, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant