Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/sandbox-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: sandbox-validation

on:
workflow_dispatch:
pull_request:
paths:
- 'apps/agent/**'
- 'apps/server/**'
- '.github/workflows/sandbox-validation.yml'
push:
paths:
- 'apps/agent/**'
- 'apps/server/**'
- '.github/workflows/sandbox-validation.yml'

permissions:
contents: read

jobs:
sandbox:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: zakura_test
POSTGRES_PASSWORD: zakura_test
POSTGRES_DB: zakura_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U zakura_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26.4'
cache-dependency-path: |
apps/agent/go.sum
apps/server/go.sum
- name: Agent tests and race detector
working-directory: apps/agent
run: |
go test -race ./...
go vet ./...
- name: Prepare sandbox test image
run: |
docker build -t zakura-sandbox-ci:local - <<'DOCKERFILE'
FROM alpine:3.23
RUN apk add --no-cache bash
DOCKERFILE
echo "ZAKURA_SANDBOX_IMAGE=$(docker image inspect zakura-sandbox-ci:local --format '{{.Id}}')" >> "$GITHUB_ENV"
- name: Real container isolation and lifecycle checks
working-directory: apps/agent
env:
ZAKURA_SANDBOX_INTEGRATION: '1'
run: go test -count=1 -v ./internal/sandbox
- name: Build runner for server integration
working-directory: apps/agent
run: go build -o "$RUNNER_TEMP/zakura-sandbox-agent" ./cmd/zakura-agent
- name: Real server to runner sandbox integration
working-directory: apps/server
env:
ZAKURA_SERVER_SANDBOX_INTEGRATION: '1'
CGO_ENABLED: '1'
run: ZAKURA_SANDBOX_AGENT_BINARY="$RUNNER_TEMP/zakura-sandbox-agent" go test -count=1 -v ./internal/runtime -run '^TestSandboxServerRunnerDockerIntegration$' -timeout 3m
- name: PostgreSQL config compare-and-swap integration
working-directory: apps/server
env:
ZAKURA_TEST_POSTGRES_DSN: postgres://zakura_test:zakura_test@127.0.0.1:5432/zakura_test?sslmode=disable
run: go test -count=1 -v ./internal/platform/agentconfig -run Postgres
- name: Server regression tests
working-directory: apps/server
env:
CGO_ENABLED: '1'
run: |
go test ./...
go vet ./...
63 changes: 49 additions & 14 deletions apps/agent/internal/rpc/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,34 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"sync"

"github.com/Moonrend/Zakura/apps/agent/internal/docker"
"github.com/Moonrend/Zakura/apps/agent/internal/host"
"github.com/Moonrend/Zakura/apps/agent/internal/sandbox"
"github.com/Moonrend/Zakura/apps/agent/internal/sys"
)

type Handler struct {
Kind string
StorageRoot string
jobs *host.Registry
ptys map[string]*host.LiveStream
mu sync.Mutex
Kind string
StorageRoot string
jobs *host.Registry
sandboxJobs *sandbox.Registry
sandboxEnforced bool
ptys map[string]*host.LiveStream
mu sync.Mutex
}

func New(kind, storageRoot string) *Handler {
return &Handler{
Kind: kind,
StorageRoot: storageRoot,
jobs: host.NewRegistry(),
ptys: map[string]*host.LiveStream{},
Kind: kind,
StorageRoot: storageRoot,
jobs: host.NewRegistry(),
sandboxJobs: sandbox.NewRegistry(),
sandboxEnforced: sandboxEnforcement(os.Getenv("ZAKURA_SANDBOX_ENABLED")),
ptys: map[string]*host.LiveStream{},
}
}

Expand All @@ -38,7 +44,24 @@ func (h *Handler) workspace(spaceID string) string {
func (h *Handler) Dispatch(ctx context.Context, msg Msg, send func(Msg)) {
var err error
var result any

// Do not let an explicit isolation request silently use an unsupported
// execution transport. Docker management is not the sandbox backend API.
switch msg.Method {
case "docker.run", "docker.exec", "docker.exec.start", "docker.attach", "docker.recreate":
requested, policyErr := h.sandboxRequested(msg.Params)
if policyErr != nil {
send(Err(msg.ID, policyErr.Error()))
return
}
if requested {
send(Err(msg.ID, "this execution method is unavailable in sandbox mode; use host.exec or host.exec.start"))
return
}
}
switch msg.Method {
case "sandbox.policy":
result = map[string]any{"enforced": h.sandboxEnforced, "defaultExecutionMode": map[bool]string{true: "sandbox", false: "host"}[h.sandboxEnforced], "hostModeIsolated": false, "sandbox": sandbox.Policy()}
case "sys.info":
var p struct {
Light bool `json:"light"`
Expand Down Expand Up @@ -77,15 +100,24 @@ func (h *Handler) Dispatch(ctx context.Context, msg Msg, send func(Msg)) {
case "host.fs.rename":
result, err = h.fsRename(msg.Params)
case "host.exec":
result, err = h.hostExec(msg.Params)
result, err = h.execWithPolicy(ctx, msg.Params, false)
case "host.exec.start":
result, err = h.hostExecStart(msg.Params)
result, err = h.execWithPolicy(ctx, msg.Params, true)
case "host.exec.get":
result, err = h.hostExecGet(msg.Params)
result, err = h.jobWithPolicy(msg.Params, false)
case "host.exec.kill":
result, err = h.hostExecKill(msg.Params)
result, err = h.jobWithPolicy(msg.Params, true)
case "host.pty.start":
result, err = h.ptyStart(msg.Params, send)
if enabled, policyErr := h.sandboxRequested(msg.Params); policyErr != nil {
err = policyErr
} else if enabled {
err = fmt.Errorf("interactive execution is unavailable in sandbox mode; use non-interactive sandbox execution")
} else {
result, err = h.ptyStart(msg.Params, send)
if err == nil {
result, err = executionResult(result, "host")
}
}
case "host.pty.write":
err = h.ptyWrite(msg.Params)
result = map[string]bool{"ok": err == nil}
Expand Down Expand Up @@ -191,6 +223,9 @@ func (h *Handler) Dispatch(ctx context.Context, msg Msg, send func(Msg)) {
_ = json.Unmarshal(msg.Params, &p)
message = host.ScrubHostPathsInMessage(h.rootOf(p), message)
}
if strings.HasPrefix(msg.Method, "host.exec") {
message = host.ScrubHostPathsInMessage(h.StorageRoot, message)
}
send(Err(msg.ID, message))
return
}
Expand Down
178 changes: 178 additions & 0 deletions apps/agent/internal/rpc/sandbox.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package rpc

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/Moonrend/Zakura/apps/agent/internal/sandbox"
)

var sandboxSpaceID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)

// The authenticated control plane supplies the space identity. Never accept an
// arbitrary root, a storage-root fallback, or a symlink into another workspace.
func (h *Handler) sandboxRoot(spaceID string) (string, error) {
if !sandboxSpaceID.MatchString(spaceID) {
return "", fmt.Errorf("a valid spaceId is required for sandbox execution")
}
root, err := filepath.Abs(h.StorageRoot)
if err != nil || h.StorageRoot == "" {
return "", fmt.Errorf("sandbox storage root is unavailable")
}
resolved, err := filepath.EvalSymlinks(root)
if err != nil || resolved != root {
return "", fmt.Errorf("sandbox storage root must be an existing non-symlink directory")
}
for _, part := range []string{"spaces", spaceID, "workspace"} {
root = filepath.Join(root, part)
if err := os.Mkdir(root, 0755); err != nil && !os.IsExist(err) {
return "", fmt.Errorf("sandbox workspace is unavailable")
}
info, err := os.Lstat(root)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("sandbox workspace must use real directories")
}
}
return root, nil
}

type sandboxRequest struct {
sandbox.ExecParams
SpaceID string `json:"spaceId"`
}

func (h *Handler) sandboxExecContext(ctx context.Context, raw json.RawMessage) (any, error) {
var p sandboxRequest
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid sandbox execution parameters")
}
root, err := h.sandboxRoot(p.SpaceID)
if err != nil {
return nil, err
}
return sandbox.Run(ctx, root, p.ExecParams)
}

func (h *Handler) sandboxExecStart(raw json.RawMessage) (any, error) {
var p sandboxRequest
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid sandbox execution parameters")
}
root, err := h.sandboxRoot(p.SpaceID)
if err != nil {
return nil, err
}
return h.sandboxJobs.Start(root, p.ExecParams)
}

func (h *Handler) sandboxJob(raw json.RawMessage, cancel bool) (any, error) {
var p struct {
ID string `json:"id"`
SpaceID string `json:"spaceId"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("invalid sandbox job parameters")
}
root, err := h.sandboxRoot(p.SpaceID)
if err != nil {
return nil, err
}
var snap *sandbox.JobSnap
if cancel {
snap = h.sandboxJobs.KillScoped(p.ID, root)
} else {
snap = h.sandboxJobs.GetScoped(p.ID, root)
}
if snap == nil {
return nil, fmt.Errorf("sandbox job not found")
}
return snap, nil
}

// Enforcement is captured when the authenticated runner handler is created.
// A request can select stronger isolation but cannot turn operator policy off.
func (h *Handler) sandboxRequested(raw json.RawMessage) (bool, error) {
var p struct {
ExecutionMode string `json:"executionMode"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return false, fmt.Errorf("invalid execution policy parameters")
}
if p.ExecutionMode != "" && p.ExecutionMode != "host" && p.ExecutionMode != "sandbox" {
return false, fmt.Errorf("executionMode must be host or sandbox")
}
return h.sandboxEnforced || p.ExecutionMode == "sandbox", nil
}

func executionResult(value any, mode string) (any, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
result := map[string]any{}
if err := json.Unmarshal(raw, &result); err != nil {
return nil, err
}
result["executionMode"] = mode
result["isolated"] = mode == "sandbox"
return result, nil
}

func (h *Handler) execWithPolicy(ctx context.Context, raw json.RawMessage, start bool) (any, error) {
enabled, err := h.sandboxRequested(raw)
if err != nil {
return nil, err
}
var result any
mode := "host"
if enabled {
mode = "sandbox"
if start {
result, err = h.sandboxExecStart(raw)
} else {
result, err = h.sandboxExecContext(ctx, raw)
}
} else {
if start {
result, err = h.hostExecStart(raw)
} else {
result, err = h.hostExec(raw)
}
}
if err != nil {
return nil, err
}
return executionResult(result, mode)
}

func (h *Handler) jobWithPolicy(raw json.RawMessage, cancel bool) (any, error) {
enabled, err := h.sandboxRequested(raw)
if err != nil {
return nil, err
}
var result any
mode := "host"
if enabled {
mode = "sandbox"
result, err = h.sandboxJob(raw, cancel)
} else if cancel {
result, err = h.hostExecKill(raw)
} else {
result, err = h.hostExecGet(raw)
}
if err != nil {
return nil, err
}
return executionResult(result, mode)
}

// Unknown nonempty values fail closed rather than silently turning policy off.
func sandboxEnforcement(value string) bool {
value = strings.TrimSpace(value)
return value != "" && !strings.EqualFold(value, "false") && value != "0"
}
Loading