-
Notifications
You must be signed in to change notification settings - Fork 1
feat(sandbox): workflow-sandbox-runner agent + remote-runner config (infra-admin P3b PR8/12) #848
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| // Package main is the entrypoint for the workflow-sandbox-runner agent. | ||
| // | ||
| // The agent serves the SandboxExecService gRPC interface over mTLS + bearer-token auth. | ||
| // It resolves secret:// references in env values server-side before launching commands, | ||
| // and clamps requested security profiles to a safe maximum (permissive → standard). | ||
| // | ||
| // Design: docs/decisions/0019-remote-sandbox-agent.md (ADR 0019) | ||
| package main | ||
|
|
||
| import ( | ||
| "crypto/tls" | ||
| "crypto/x509" | ||
| "flag" | ||
| "fmt" | ||
| "log/slog" | ||
| "net" | ||
| "os" | ||
| "runtime/debug" | ||
|
|
||
| "google.golang.org/grpc" | ||
| "google.golang.org/grpc/credentials" | ||
| "google.golang.org/grpc/credentials/insecure" | ||
|
|
||
| pb "github.com/GoCodeAlone/workflow/plugin/external/proto" | ||
| "github.com/GoCodeAlone/workflow/secrets" | ||
| ) | ||
|
|
||
| // version is set at build time via -ldflags "-X main.version=<version>". | ||
| // When built without ldflags (e.g. go run), buildVersion() reads the module | ||
| // version from the embedded build info, falling back to "dev". | ||
| var version = buildVersion() | ||
|
|
||
| func buildVersion() string { | ||
| if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { | ||
| return info.Main.Version | ||
| } | ||
| return "dev" | ||
| } | ||
|
|
||
| func main() { | ||
| showVersion := flag.Bool("version", false, "Print version and exit") | ||
| listenAddr := flag.String("addr", ":50051", "gRPC listen address (host:port)") | ||
| certFile := flag.String("tls-cert", "", "TLS certificate file (PEM)") | ||
| keyFile := flag.String("tls-key", "", "TLS private key file (PEM)") | ||
| caFile := flag.String("tls-ca", "", "CA certificate for mTLS client auth (PEM; enables mTLS when set). REQUIRED (or --token) unless --allow-unauthenticated.") | ||
| bearerToken := flag.String("token", "", "Expected bearer token for RPC auth (from SANDBOX_RUNNER_TOKEN env if unset). REQUIRED (or --tls-ca) unless --allow-unauthenticated.") | ||
| allowUnauth := flag.Bool("allow-unauthenticated", false, "Permit startup with NO auth (no token AND no mTLS). DANGEROUS — local/dev only, never production.") | ||
| secretsBackend := flag.String("secrets-backend", "env", "Secrets backend for secret:// resolution: env, file") | ||
| secretsDir := flag.String("secrets-dir", "", "Directory for 'file' secrets backend") | ||
| secretsEnvPrefix := flag.String("secrets-env-prefix", "", "Env-var prefix for 'env' secrets backend") | ||
| flag.Parse() | ||
|
|
||
| if *showVersion { | ||
| fmt.Println(version) | ||
| os.Exit(0) | ||
| } | ||
|
|
||
| // Allow token to come from environment for 12-factor deployment. | ||
| token := *bearerToken | ||
| if token == "" { | ||
| token = os.Getenv("SANDBOX_RUNNER_TOKEN") | ||
| } | ||
|
|
||
| // Refuse to start as an unauthenticated remote code executor unless the | ||
| // operator explicitly opts in. "Authenticated" means a bearer token OR mTLS | ||
| // (a CA configured for client-cert verification). | ||
| if err := checkAuthRequirement(token, *caFile, *allowUnauth); err != nil { | ||
| slog.Error("sandbox-runner: refusing to start", "err", err) | ||
| os.Exit(1) | ||
| } | ||
| if *allowUnauth && token == "" && *caFile == "" { | ||
| slog.Warn("sandbox-runner: WARNING — running with NO authentication (no token, no mTLS); do NOT use in production") | ||
| } | ||
|
|
||
| // Build secrets provider for server-side secret:// resolution. | ||
| var provider secrets.Provider | ||
| switch *secretsBackend { | ||
| case "file": | ||
| if *secretsDir == "" { | ||
| slog.Error("sandbox-runner: --secrets-dir is required for file backend") | ||
| os.Exit(1) | ||
| } | ||
| provider = secrets.NewFileProvider(*secretsDir) | ||
| default: // "env" | ||
| provider = secrets.NewEnvProvider(*secretsEnvPrefix) | ||
| } | ||
|
|
||
| // Build gRPC server options. | ||
| serverOpts, err := buildServerOptions(*certFile, *keyFile, *caFile) | ||
| if err != nil { | ||
| slog.Error("sandbox-runner: failed to build TLS options", "err", err) | ||
| os.Exit(1) | ||
| } | ||
|
|
||
| // Stream interceptor for bearer-token auth. | ||
| serverOpts = append(serverOpts, grpc.StreamInterceptor(newBearerStreamInterceptor(token))) | ||
|
|
||
| grpcServer := grpc.NewServer(serverOpts...) | ||
| srv := newSandboxExecServer(provider, slog.Default()) | ||
| pb.RegisterSandboxExecServiceServer(grpcServer, srv) | ||
|
|
||
| lis, err := net.Listen("tcp", *listenAddr) | ||
| if err != nil { | ||
| slog.Error("sandbox-runner: listen failed", "addr", *listenAddr, "err", err) | ||
| os.Exit(1) | ||
| } | ||
|
|
||
| slog.Info("sandbox-runner: starting", "addr", *listenAddr, "version", version) | ||
| if err := grpcServer.Serve(lis); err != nil { | ||
| slog.Error("sandbox-runner: serve error", "err", err) | ||
| os.Exit(1) | ||
| } | ||
| } | ||
|
|
||
| // checkAuthRequirement enforces that the agent — a remote command executor — | ||
| // has SOME authentication configured. Auth is satisfied by either a non-empty | ||
| // bearer token or mTLS (a CA file for client-cert verification). If neither is | ||
| // present, startup is refused unless allowUnauth is explicitly set. | ||
| func checkAuthRequirement(token, caFile string, allowUnauth bool) error { | ||
| if token != "" || caFile != "" { | ||
| return nil | ||
| } | ||
| if allowUnauth { | ||
| return nil | ||
| } | ||
| return fmt.Errorf("no authentication configured: set --token (bearer auth) or --tls-ca (mTLS), " + | ||
| "or pass --allow-unauthenticated for local/dev only (never production)") | ||
| } | ||
|
|
||
| // buildServerOptions constructs gRPC server credentials from the supplied TLS files. | ||
| // If certFile and keyFile are empty, the server starts without TLS (test/dev only). | ||
| // If caFile is also set, mTLS client authentication is enabled. | ||
| func buildServerOptions(certFile, keyFile, caFile string) ([]grpc.ServerOption, error) { | ||
| // mTLS requires the server to present its own certificate; a CA alone only | ||
| // configures client-cert verification. Refuse a --tls-ca that lacks the | ||
| // server cert/key, otherwise the agent would start INSECURE while the | ||
| // operator believes mTLS is on (checkAuthRequirement treats --tls-ca as auth). | ||
| if caFile != "" && (certFile == "" || keyFile == "") { | ||
| return nil, fmt.Errorf("--tls-ca requires both --tls-cert and --tls-key (mTLS needs the server's own certificate)") | ||
| } | ||
| if certFile == "" && keyFile == "" { | ||
| // No TLS — insecure mode for local development / testing. | ||
| return []grpc.ServerOption{grpc.Creds(insecure.NewCredentials())}, nil | ||
| } | ||
| if certFile == "" || keyFile == "" { | ||
| return nil, fmt.Errorf("both --tls-cert and --tls-key must be set together") | ||
| } | ||
| cert, err := tls.LoadX509KeyPair(certFile, keyFile) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("load server cert/key: %w", err) | ||
| } | ||
| tlsCfg := &tls.Config{ | ||
| Certificates: []tls.Certificate{cert}, | ||
| MinVersion: tls.VersionTLS13, | ||
| } | ||
| if caFile != "" { | ||
| caPEM, err := os.ReadFile(caFile) //nolint:gosec // G304: path is operator-supplied via flag | ||
| if err != nil { | ||
| return nil, fmt.Errorf("read CA cert: %w", err) | ||
| } | ||
| pool := x509.NewCertPool() | ||
| if !pool.AppendCertsFromPEM(caPEM) { | ||
| return nil, fmt.Errorf("failed to parse CA cert from %q", caFile) | ||
| } | ||
| tlsCfg.ClientCAs = pool | ||
| tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert | ||
| } | ||
| return []grpc.ServerOption{grpc.Creds(credentials.NewTLS(tlsCfg))}, nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| package main | ||
|
|
||
| import "testing" | ||
|
|
||
| // TestCheckAuthRequirement covers the security gate that refuses to start an | ||
| // unauthenticated remote code executor unless --allow-unauthenticated is set. | ||
| func TestCheckAuthRequirement(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| token string | ||
| caFile string | ||
| allowUnauth bool | ||
| wantErr bool | ||
| }{ | ||
| {name: "token only", token: "tok", wantErr: false}, | ||
| {name: "mTLS only", caFile: "/etc/ca.crt", wantErr: false}, | ||
| {name: "token and mTLS", token: "tok", caFile: "/etc/ca.crt", wantErr: false}, | ||
| {name: "no auth — refused", wantErr: true}, | ||
| {name: "no auth + allow-unauthenticated — permitted", allowUnauth: true, wantErr: false}, | ||
| // allow-unauthenticated is a no-op when auth IS configured (no error either way). | ||
| {name: "token + allow-unauthenticated", token: "tok", allowUnauth: true, wantErr: false}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| err := checkAuthRequirement(tt.token, tt.caFile, tt.allowUnauth) | ||
| if tt.wantErr && err == nil { | ||
| t.Errorf("checkAuthRequirement(%q,%q,%v): expected error, got nil", tt.token, tt.caFile, tt.allowUnauth) | ||
| } | ||
| if !tt.wantErr && err != nil { | ||
| t.Errorf("checkAuthRequirement(%q,%q,%v): unexpected error: %v", tt.token, tt.caFile, tt.allowUnauth, err) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestBuildServerOptions_CAWithoutCertKey_Error verifies the fail-fast guard: | ||
| // --tls-ca set without --tls-cert/--tls-key must error rather than silently | ||
| // starting with insecure transport (which checkAuthRequirement believes is mTLS). | ||
| func TestBuildServerOptions_CAWithoutCertKey_Error(t *testing.T) { | ||
| if _, err := buildServerOptions("", "", "/etc/ca.crt"); err == nil { | ||
| t.Error("--tls-ca without cert/key must error, got nil") | ||
| } | ||
| if _, err := buildServerOptions("/etc/server.crt", "", "/etc/ca.crt"); err == nil { | ||
| t.Error("--tls-ca with cert but no key must error, got nil") | ||
| } | ||
| if _, err := buildServerOptions("", "/etc/server.key", "/etc/ca.crt"); err == nil { | ||
| t.Error("--tls-ca with key but no cert must error, got nil") | ||
| } | ||
| } | ||
|
|
||
| // TestBuildServerOptions_NoTLS_Insecure verifies the no-TLS path still returns | ||
| // insecure credentials (used with --allow-unauthenticated or a bearer token). | ||
| func TestBuildServerOptions_NoTLS_Insecure(t *testing.T) { | ||
| opts, err := buildServerOptions("", "", "") | ||
| if err != nil { | ||
| t.Fatalf("buildServerOptions(no TLS): %v", err) | ||
| } | ||
| if len(opts) == 0 { | ||
| t.Error("expected at least one server option for insecure mode") | ||
| } | ||
| } | ||
|
|
||
| // TestBuildServerOptions_CertWithoutKey_Error verifies cert/key both-or-neither. | ||
| func TestBuildServerOptions_CertWithoutKey_Error(t *testing.T) { | ||
| if _, err := buildServerOptions("/etc/server.crt", "", ""); err == nil { | ||
| t.Error("cert without key must error, got nil") | ||
| } | ||
| if _, err := buildServerOptions("", "/etc/server.key", ""); err == nil { | ||
| t.Error("key without cert must error, got nil") | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.