Skip to content

Commit ea70821

Browse files
heusalagroupbotaibuddy
andauthored
sandbox-jsrun: add code.sandbox.js.run handler with bounded output, timeout, structured stderr; include interface doc (#53)
Co-authored-by: aibuddy <aibuddy@dev.hg.fi>
1 parent 3da222b commit ea70821

File tree

4 files changed

+648
-0
lines changed

4 files changed

+648
-0
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
## Interface: code.sandbox.js.run
2+
3+
The JavaScript sandbox executes a short snippet with a strict deny-by-default security model and bounded resource usage. It is intended for tiny, deterministic computations on assistant-provided inputs without ambient access to the host environment.
4+
5+
- Purpose: run isolated JS with no filesystem, network, timers, or console; only minimal host bindings are exposed.
6+
- Security: deny-by-default; only `emit` and `read_input` are available. No `require`, no `console`, no timers, no Promise scheduling. Treat untrusted code as hostile; limits are enforced best-effort.
7+
- Limits: wall-clock timeout and output size cap; output is truncated when exceeding the cap and an `OUTPUT_LIMIT` error is returned.
8+
9+
### JSON contract
10+
11+
- stdin (object):
12+
- `source` (string, required): JavaScript source code to evaluate.
13+
- `input` (string, required): Opaque input made available to the script via `read_input()`.
14+
- `limits` (object, required):
15+
- `wall_ms` (int, optional): Maximum wall-clock time in milliseconds. Default 1000 ms.
16+
- `output_kb` (int, optional): Maximum output size in KiB before truncation. Default 64 KiB.
17+
18+
- stdout (on success):
19+
```json
20+
{"output":"<string>"}
21+
```
22+
23+
- stderr (on failure): single-line JSON with a stable error code:
24+
```json
25+
{"code":"EVAL_ERROR","message":"<details>"}
26+
{"code":"TIMEOUT","message":"execution exceeded <ms> ms"}
27+
{"code":"OUTPUT_LIMIT","message":"output exceeded <KB> KB"}
28+
```
29+
30+
### Host bindings available inside the VM
31+
32+
- `read_input(): string` — returns the provided `input` string.
33+
- `emit(s: string): void` — appends `s` to the output buffer. When the buffer reaches `output_kb`, the VM aborts with `OUTPUT_LIMIT` after returning truncated stdout.
34+
35+
All other globals are intentionally undefined (e.g., `typeof require === 'undefined'`, `typeof console === 'undefined'`, `typeof setTimeout === 'undefined'`).
36+
37+
### Examples
38+
39+
- Echo input:
40+
```json
41+
{
42+
"source": "emit(read_input())",
43+
"input": "hello",
44+
"limits": {"output_kb": 4}
45+
}
46+
```
47+
Expected stdout:
48+
```json
49+
{"output":"hello"}
50+
```
51+
52+
- Output limit with truncation and error:
53+
```json
54+
{
55+
"source": "emit(read_input())",
56+
"input": "<1500 x 'a'>",
57+
"limits": {"output_kb": 1}
58+
}
59+
```
60+
Expected behavior: stdout contains 1024 bytes of `"a"`; stderr is `{"code":"OUTPUT_LIMIT",...}` and the process exits non‑zero.
61+
62+
- Malicious loop (timeout):
63+
```json
64+
{
65+
"source": "for(;;) {}",
66+
"input": "",
67+
"limits": {"wall_ms": 100}
68+
}
69+
```
70+
Expected behavior: process is interrupted within ~100ms with `stderr` `{"code":"TIMEOUT",...}` and non‑zero exit; stdout is empty.
71+
72+
### Quick verification via CLI (local repository)
73+
74+
You can verify the interface using the existing unit tests:
75+
```bash
76+
# Run a subset of tests for the sandbox
77+
go test ./internal/tools/jsrun -run 'TestRun_EmitReadInput_Succeeds|TestRun_OutputLimit_TruncatesAndErrors|TestRun_Timeout_Interrupts' -v
78+
```
79+
These tests cover happy-path echo, output truncation, and timeout interruption.
80+
81+
### Security Model
82+
83+
- Deny-by-default capabilities: the VM exposes only `emit` and `read_input`; there is no filesystem, network, clock, process, or environment access.
84+
- No timers/async: `setTimeout`, `setInterval`, Promises scheduling, and microtask queues are unavailable by default.
85+
- Deterministic budget: wall-time and output-size limits enforce bounded execution; long-running or unbounded loops will be interrupted.
86+
- Secrets hygiene: do not include secrets in `source` or `input`; error logs may contain minimal metadata necessary for troubleshooting.
87+
88+
### Pitfalls
89+
90+
- Large computations or accidental loops may hit the `wall_ms` timeout.
91+
- Emitting excessive data triggers `OUTPUT_LIMIT` with truncated output and a non-zero exit.
92+
93+
### Status
94+
95+
- Implementation: `internal/tools/jsrun/handler.go`
96+
- Tests: `internal/tools/jsrun/handler_test.go`
97+
- Consumers: intended for future internal tool wiring; not exposed as an external tool binary at this time.

internal/tools/jsrun/handler.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package jsrun
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"os"
10+
"path/filepath"
11+
"time"
12+
13+
"github.com/dop251/goja"
14+
)
15+
16+
// Input models the expected stdin JSON for code.sandbox.js.run
17+
type Input struct {
18+
Source string `json:"source"`
19+
Input string `json:"input"`
20+
Limits struct {
21+
WallMS int `json:"wall_ms"`
22+
OutputKB int `json:"output_kb"`
23+
} `json:"limits"`
24+
}
25+
26+
// Output is the successful stdout JSON shape
27+
type Output struct {
28+
Output string `json:"output"`
29+
}
30+
31+
// Error represents a structured error payload for stderr JSON
32+
type Error struct {
33+
Code string `json:"code"`
34+
Message string `json:"message"`
35+
}
36+
37+
var (
38+
errOutputLimit = errors.New("OUTPUT_LIMIT")
39+
errTimeout = errors.New("TIMEOUT")
40+
)
41+
42+
// Run executes the provided JavaScript source with minimal host bindings.
43+
// Returns (stdoutJSON, stderrJSON, err). On OUTPUT_LIMIT, returns truncated
44+
// stdout along with stderr error JSON and a non-nil error.
45+
func Run(raw []byte) ([]byte, []byte, error) {
46+
start := time.Now()
47+
var in Input
48+
if err := json.Unmarshal(raw, &in); err != nil {
49+
return nil, mustMarshalError("INVALID_INPUT", "invalid JSON: "+err.Error()), err
50+
}
51+
if in.Source == "" {
52+
return nil, mustMarshalError("INVALID_INPUT", "missing source"), errors.New("invalid input")
53+
}
54+
55+
// Default output cap: 64 KiB if not provided or invalid
56+
maxKB := in.Limits.OutputKB
57+
if maxKB <= 0 {
58+
maxKB = 64
59+
}
60+
capBytes := maxKB * 1024
61+
62+
// Prepare bounded output buffer
63+
var outBuf bytes.Buffer
64+
65+
// Build a Goja VM with minimal bindings
66+
vm := goja.New()
67+
68+
// Helper to write to bounded buffer and signal limit
69+
writeAndMaybeLimit := func(s string) error {
70+
writeBounded(&outBuf, s, capBytes)
71+
if outBuf.Len() >= capBytes && len(s) > capBytes {
72+
return errOutputLimit
73+
}
74+
return nil
75+
}
76+
77+
// Bind read_input(): returns provided input string
78+
if err := vm.Set("read_input", func() string { return in.Input }); err != nil {
79+
return nil, mustMarshalError("EVAL_ERROR", "failed to bind read_input"), err
80+
}
81+
82+
// Bind emit(s): appends to bounded buffer
83+
if err := vm.Set("emit", func(call goja.FunctionCall) goja.Value {
84+
if len(call.Arguments) > 0 {
85+
arg := call.Arguments[0].String()
86+
if e := writeAndMaybeLimit(arg); e != nil {
87+
// Trigger a JS exception that we map after execution
88+
panic(errOutputLimit)
89+
}
90+
}
91+
return goja.Undefined()
92+
}); err != nil {
93+
return nil, mustMarshalError("EVAL_ERROR", "failed to bind emit"), err
94+
}
95+
96+
// Timeout handling with interrupt
97+
wall := in.Limits.WallMS
98+
if wall <= 0 {
99+
wall = 1000 // 1s default
100+
}
101+
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(wall)*time.Millisecond)
102+
defer cancel()
103+
104+
// Arrange to interrupt VM on timeout
105+
done := make(chan struct{})
106+
var runErr error
107+
go func() {
108+
defer close(done)
109+
defer func() {
110+
if r := recover(); r != nil {
111+
// Propagate as error for classification below
112+
if errVal, ok := r.(error); ok {
113+
runErr = errVal
114+
} else {
115+
runErr = fmt.Errorf("panic: %v", r)
116+
}
117+
}
118+
}()
119+
_, runErr = vm.RunString(in.Source)
120+
}()
121+
122+
select {
123+
case <-done:
124+
// Completed or panicked; classify below
125+
case <-ctx.Done():
126+
vm.Interrupt("timeout")
127+
<-done
128+
runErr = errTimeout
129+
}
130+
131+
// Classify results
132+
if runErr != nil {
133+
switch runErr {
134+
case errOutputLimit:
135+
outJSON, mErr := json.Marshal(Output{Output: outBuf.String()})
136+
if mErr != nil {
137+
return nil, mustMarshalError("EVAL_ERROR", mErr.Error()), mErr
138+
}
139+
// audit before returning
140+
_ = appendAudit(map[string]any{ //nolint:errcheck // best-effort audit
141+
"ts": time.Now().UTC().Format(time.RFC3339Nano),
142+
"tool": "code.sandbox.js.run",
143+
"span": "tools.js.run",
144+
"ms": time.Since(start).Milliseconds(),
145+
"bytes_out": len(outBuf.String()),
146+
"event": "OUTPUT_LIMIT",
147+
})
148+
return outJSON, mustMarshalError("OUTPUT_LIMIT", fmt.Sprintf("output exceeded %d KB", maxKB)), errOutputLimit
149+
case errTimeout:
150+
// audit before returning
151+
_ = appendAudit(map[string]any{ //nolint:errcheck // best-effort audit
152+
"ts": time.Now().UTC().Format(time.RFC3339Nano),
153+
"tool": "code.sandbox.js.run",
154+
"span": "tools.js.run",
155+
"ms": time.Since(start).Milliseconds(),
156+
"bytes_out": outBuf.Len(),
157+
"event": "TIMEOUT",
158+
})
159+
return nil, mustMarshalError("TIMEOUT", fmt.Sprintf("execution exceeded %d ms", wall)), errTimeout
160+
default:
161+
// audit before returning
162+
_ = appendAudit(map[string]any{ //nolint:errcheck // best-effort audit
163+
"ts": time.Now().UTC().Format(time.RFC3339Nano),
164+
"tool": "code.sandbox.js.run",
165+
"span": "tools.js.run",
166+
"ms": time.Since(start).Milliseconds(),
167+
"bytes_out": outBuf.Len(),
168+
"event": "EVAL_ERROR",
169+
})
170+
return nil, mustMarshalError("EVAL_ERROR", runErr.Error()), runErr
171+
}
172+
}
173+
174+
outJSON, mErr := json.Marshal(Output{Output: outBuf.String()})
175+
if mErr != nil {
176+
return nil, mustMarshalError("EVAL_ERROR", mErr.Error()), mErr
177+
}
178+
// success audit
179+
_ = appendAudit(map[string]any{ //nolint:errcheck // best-effort audit
180+
"ts": time.Now().UTC().Format(time.RFC3339Nano),
181+
"tool": "code.sandbox.js.run",
182+
"span": "tools.js.run",
183+
"ms": time.Since(start).Milliseconds(),
184+
"bytes_out": len(outBuf.String()),
185+
"event": "success",
186+
})
187+
return outJSON, nil, nil
188+
}
189+
190+
func mustMarshalError(code, msg string) []byte {
191+
b, err := json.Marshal(Error{Code: code, Message: msg})
192+
if err != nil {
193+
// Fallback minimal JSON to avoid panics in error paths
194+
return []byte(`{"code":"` + code + `","message":"` + msg + `"}`)
195+
}
196+
return b
197+
}
198+
199+
func writeBounded(buf *bytes.Buffer, s string, capBytes int) {
200+
if capBytes <= 0 {
201+
_ = buf.WriteByte(0) // unreachable, but keep logic safe
202+
return
203+
}
204+
remain := capBytes - buf.Len()
205+
if remain <= 0 {
206+
return
207+
}
208+
bs := []byte(s)
209+
if len(bs) > remain {
210+
buf.Write(bs[:remain])
211+
return
212+
}
213+
buf.Write(bs)
214+
}
215+
216+
// appendAudit writes an NDJSON line under .goagent/audit/YYYYMMDD.log at the repo root.
217+
func appendAudit(entry any) error {
218+
b, err := json.Marshal(entry)
219+
if err != nil {
220+
return err
221+
}
222+
root := moduleRoot()
223+
dir := filepath.Join(root, ".goagent", "audit")
224+
if err := os.MkdirAll(dir, 0o755); err != nil {
225+
return err
226+
}
227+
fname := time.Now().UTC().Format("20060102") + ".log"
228+
path := filepath.Join(dir, fname)
229+
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
230+
if err != nil {
231+
return err
232+
}
233+
defer func() { _ = f.Close() }() //nolint:errcheck // best-effort close
234+
if _, err := f.Write(append(b, '\n')); err != nil {
235+
return err
236+
}
237+
return nil
238+
}
239+
240+
// moduleRoot walks upward from CWD to the directory containing go.mod; falls back to CWD.
241+
func moduleRoot() string {
242+
cwd, err := os.Getwd()
243+
if err != nil || cwd == "" {
244+
return "."
245+
}
246+
dir := cwd
247+
for {
248+
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
249+
return dir
250+
}
251+
parent := filepath.Dir(dir)
252+
if parent == dir {
253+
return cwd
254+
}
255+
dir = parent
256+
}
257+
}

0 commit comments

Comments
 (0)