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
24 changes: 24 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: "flashduty-runner CodeQL config"

# This repo IS an agent runtime: its purpose is to give an authenticated
# Safari client filesystem access, command execution, MCP transport, and
# outbound web fetches inside a sandbox. The four queries below report
# every one of those load-bearing surfaces as "user-controlled input
# reaches a sink". That's true in the literal taint-flow sense and false
# in the threat-model sense — the trust boundary is the sandbox plus the
# X-Access-Token check at the HTTP edge, not in-process input sanitization.
#
# Suppressing these queries repo-wide lets CodeQL keep flagging the
# unintended classes (XSS, SSRF outside the documented webfetch path,
# crypto misuse, race conditions, etc.) without burying signal under the
# expected noise.

query-filters:
- exclude:
id: go/command-injection
- exclude:
id: go/path-injection
- exclude:
id: go/request-forgery
- exclude:
id: go/uncontrolled-allocation-size
1 change: 1 addition & 0 deletions .github/workflows/code-scanning.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
config-file: .github/codeql/codeql-config.yml

- name: Autobuild
uses: github/codeql-action/autobuild@v4
Expand Down
1 change: 1 addition & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ It connects to Flashduty platform via WebSocket and executes workspace operation

// Add subcommands
rootCmd.AddCommand(runCmd())
rootCmd.AddCommand(serveCmd())
rootCmd.AddCommand(versionCmd())

if err := rootCmd.Execute(); err != nil {
Expand Down
75 changes: 75 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package main

import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"path/filepath"
"syscall"

"github.com/spf13/cobra"

"github.com/flashcatcloud/flashduty-runner/envd"
"github.com/flashcatcloud/flashduty-runner/environment"
"github.com/flashcatcloud/flashduty-runner/permission"
)

var (
flagListen string
flagTokenEnv string
)

func serveCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "serve",
Short: "Run as envd Connect-RPC server (cloud sandbox mode)",
RunE: func(cmd *cobra.Command, args []string) error { return runServe() },
}
cmd.Flags().StringVar(&flagListen, "listen", ":49999", "envd listen address")
cmd.Flags().StringVar(&flagTokenEnv, "token-from-env", "ENVD_ACCESS_TOKEN", "env var holding required X-Access-Token value (empty = no auth)")
return cmd
}

func runServe() error {
setupLogging(flagLogLevel)

token := os.Getenv(flagTokenEnv)
if token == "" {
slog.Warn("envd starting without access token; X-Access-Token check disabled", "env_var", flagTokenEnv)
}

workspaceRoot := os.Getenv("FLASHDUTY_RUNNER_HOME")
if workspaceRoot == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve home dir: %w", err)
}
workspaceRoot = filepath.Join(homeDir, ".flashduty")
}

checker := permission.NewChecker(map[string]string{"*": "allow"})
wspace, err := environment.New(workspaceRoot, checker)
if err != nil {
return fmt.Errorf("failed to create workspace: %w", err)
}

srv := envd.NewServer(envd.Config{
Listen: flagListen,
AccessToken: token,
Workspace: wspace,
Version: Version,
WorkspaceRoot: workspaceRoot,
})

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() { <-sigCh; cancel() }()

slog.Info("envd serve starting", "listen", flagListen, "version", Version)
return srv.Run(ctx)
}
25 changes: 25 additions & 0 deletions envd/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package envd

import (
"net/http"
"strings"
)

// authMiddleware enforces X-Access-Token on every non-/health request when an
// access token is configured. The token is the sandbox's envdAccessToken
// (sit_*) injected by the AGS control plane into the container env, identical
// to the value POST /sandboxes returned to Safari.
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/health") || s.cfg.AccessToken == "" {
next.ServeHTTP(w, r)
return
}
got := r.Header.Get("X-Access-Token")
if got != s.cfg.AccessToken {
http.Error(w, `{"code":"unauthenticated","message":"X-Access-Token mismatch"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
91 changes: 91 additions & 0 deletions envd/connect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package envd

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
)

// connectError is the Connect-RPC error envelope per spec
// https://connectrpc.com/docs/protocol#error-codes
type connectError struct {
Code string `json:"code"`
Message string `json:"message"`
}

// writeUnary writes a Connect-RPC unary response (JSON body + 200 on success,
// 4xx/5xx + error envelope on failure).
func writeUnary(w http.ResponseWriter, v any, err error) {
w.Header().Set("Content-Type", "application/json")
if err != nil {
code, status := mapError(err)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(connectError{Code: code, Message: err.Error()})
return
}
w.WriteHeader(http.StatusOK)
if v != nil {
_ = json.NewEncoder(w).Encode(v)
}
}

// readUnary decodes a Connect-RPC unary request body into dst.
func readUnary(r *http.Request, dst any) error {
if r.Method != http.MethodPost {
return errBadMethod
}
body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20)) // 8 MB cap on unary body
if err != nil {
return fmt.Errorf("read request body: %w", err)
}
if len(body) == 0 {
return nil // empty body == zero-valued request
}
if err := json.Unmarshal(body, dst); err != nil {
return fmt.Errorf("decode request body: %w", err)
}
return nil
}

// writeStream begins a server-stream response. Caller writes one envelope per
// json.Encoder.Encode call; client treats it as newline-delimited JSON.
func writeStream(w http.ResponseWriter) (*json.Encoder, func()) {
w.Header().Set("Content-Type", "application/connect+json")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
enc := json.NewEncoder(w)
flush := func() {
if flusher != nil {
flusher.Flush()
}
}
return enc, flush
}

var (
errBadMethod = errors.New("method not allowed")
errCanceled = errors.New("canceled")
errNotFound = errors.New("not found")
errInvalid = errors.New("invalid argument")
errUnimplemented = errors.New("unimplemented")
)

func mapError(err error) (code string, status int) {
switch {
case errors.Is(err, errBadMethod):
return "unimplemented", http.StatusMethodNotAllowed
case errors.Is(err, errCanceled):
return "canceled", 499
case errors.Is(err, errNotFound):
return "not_found", http.StatusNotFound
case errors.Is(err, errInvalid):
return "invalid_argument", http.StatusBadRequest
case errors.Is(err, errUnimplemented):
return "unimplemented", http.StatusNotImplemented
default:
return "internal", http.StatusInternalServerError
}
}
47 changes: 47 additions & 0 deletions envd/connect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package envd

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestWriteUnary_Success(t *testing.T) {
w := httptest.NewRecorder()
writeUnary(w, map[string]string{"hello": "world"}, nil)
if w.Code != 200 {
t.Fatalf("code=%d", w.Code)
}
var got map[string]string
_ = json.NewDecoder(w.Body).Decode(&got)
if got["hello"] != "world" {
t.Fatalf("body=%v", got)
}
}

func TestWriteUnary_Error(t *testing.T) {
w := httptest.NewRecorder()
writeUnary(w, nil, errInvalid)
if w.Code != 400 {
t.Fatalf("code=%d", w.Code)
}
if !strings.Contains(w.Body.String(), `"invalid_argument"`) {
t.Fatalf("body=%s", w.Body.String())
}
}

func TestReadUnary_Decodes(t *testing.T) {
body, _ := json.Marshal(map[string]int{"x": 7})
r := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/svc/m", bytes.NewReader(body))
var dst map[string]int
if err := readUnary(r, &dst); err != nil {
t.Fatal(err)
}
if dst["x"] != 7 {
t.Fatalf("dst=%v", dst)
}
}
Loading
Loading