add exec Starlark module
Raphael Sprenger
Thu, 01 Oct 2026 18:40:53 +0200
3 files changed,
361 insertions(+),
0 deletions(-)
A
internal/policy/exec.go
@@ -0,0 +1,204 @@
+package policy + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "time" + + "go.starlark.net/starlark" +) + +// ExecModule provides the ability to execute external binary commands from Starlark policies. +type ExecModule struct{} + +func (m *ExecModule) String() string { return "<module exec>" } +func (m *ExecModule) Type() string { return "module" } +func (m *ExecModule) Freeze() {} +func (m *ExecModule) Truth() starlark.Bool { return true } +func (m *ExecModule) Hash() (uint32, error) { + return 0, errors.New("unhashable") +} + +func (m *ExecModule) Attr(name string) (starlark.Value, error) { + switch name { + case "pipe_to_process": + return starlark.NewBuiltin("pipe_to_process", m.pipeToProcess), nil + } + return nil, nil +} + +func (m *ExecModule) AttrNames() []string { + return []string{"pipe_to_process"} +} + +// pipeToProcess executes an external binary, piping input_data to stdin, +// and returns an ExecResult with code, stdout, stderr, and timed_out fields. +func (m *ExecModule) pipeToProcess(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var command string + var inputData starlark.Value = starlark.None + var argList starlark.Value = starlark.None + var timeoutVal starlark.Value = starlark.None + var envDict starlark.Value = starlark.None + + if err := starlark.UnpackArgs(b.Name(), args, kwargs, + "command", &command, + "input_data?", &inputData, + "args?", &argList, + "timeout?", &timeoutVal, + "environment?", &envDict, + ); err != nil { + return nil, err + } + + var inputBytes []byte + switch v := inputData.(type) { + case starlark.String: + inputBytes = []byte(v) + case starlark.Bytes: + inputBytes = []byte(v) + case starlark.NoneType: + inputBytes = nil + default: + return nil, fmt.Errorf("input_data must be a string or bytes, got %s", inputData.Type()) + } + + var goArgs []string + if argList != starlark.None { + lst, ok := argList.(*starlark.List) + if !ok { + return nil, fmt.Errorf("args must be a list, got %s", argList.Type()) + } + for it := range lst.Elements() { + s, ok := it.(starlark.String) + if !ok { + return nil, fmt.Errorf("args must be a list of strings, got %s", it.Type()) + } + goArgs = append(goArgs, string(s)) + } + } + + var timeoutDur = 30 * time.Second + if timeoutVal != starlark.None { + switch v := timeoutVal.(type) { + case starlark.Int: + if i, ok := v.Int64(); ok { + timeoutDur = time.Duration(i) * time.Second + } else { + return nil, errors.New("timeout must be a valid integer") + } + case starlark.Float: + f, _ := starlark.AsFloat(v) + timeoutDur = time.Duration(f) * time.Second + default: + return nil, fmt.Errorf("timeout must be a number, got %s", timeoutVal.Type()) + } + } + + hasPath := false + var env []string + if envDict != starlark.None { + dict, ok := envDict.(*starlark.Dict) + if !ok { + return nil, fmt.Errorf("environment must be a dict, got %s", envDict.Type()) + } + for _, item := range dict.Items() { + key, ok := item[0].(starlark.String) + if !ok { + return nil, fmt.Errorf("environment key must be a string, got %s", item[0].Type()) + } + val, ok := item[1].(starlark.String) + if !ok { + return nil, fmt.Errorf("environment value must be a string, got %s", item[1].Type()) + } + env = append(env, string(key)+"="+string(val)) + if key == "PATH" && len(val) > 0 { + hasPath = true + } + } + } + + if !hasPath { + if path := os.Getenv("PATH"); path != "" { + env = append(env, "PATH="+path) + } + } + + ctx, ok := thread.Local("context").(context.Context) + if !ok || ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, timeoutDur) + defer cancel() + + cmd := exec.CommandContext(ctx, command, goArgs...) + cmd.Env = env + cmd.Stdin = bytes.NewReader(inputBytes) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + + timedOut := errors.Is(ctx.Err(), context.DeadlineExceeded) + + code := 0 + if runErr != nil { + if timedOut { + code = -1 + } else if exitErr, ok := errors.AsType[*exec.ExitError](runErr); ok { + code = exitErr.ExitCode() + } else { + return nil, fmt.Errorf("exec failed: %w", runErr) + } + } + + return &ExecResult{ + code: code, + stdout: stdout.String(), + stderr: stderr.String(), + timedOut: timedOut, + }, nil +} + +// ExecResult represents the result of an external process execution. +type ExecResult struct { + code int + stdout string + stderr string + timedOut bool +} + +func (r *ExecResult) String() string { + return fmt.Sprintf("<ExecResult code=%d>", r.code) +} +func (r *ExecResult) Type() string { return "ExecResult" } +func (r *ExecResult) Freeze() {} +func (r *ExecResult) Truth() starlark.Bool { + return starlark.Bool(r.code == 0 && !r.timedOut) +} +func (r *ExecResult) Hash() (uint32, error) { + return 0, errors.New("unhashable") +} + +func (r *ExecResult) Attr(name string) (starlark.Value, error) { + switch name { + case "code": + return starlark.MakeInt(r.code), nil + case "stdout": + return starlark.String(r.stdout), nil + case "stderr": + return starlark.String(r.stderr), nil + case "timed_out": + return starlark.Bool(r.timedOut), nil + } + return nil, nil +} + +func (r *ExecResult) AttrNames() []string { + return []string{"code", "stdout", "stderr", "timed_out"} +}
A
internal/policy/exec_test.go
@@ -0,0 +1,156 @@
+package policy + +import ( + "log" + "os" + "path" + "runtime" + "testing" + + "postern/internal/db" + + "go.starlark.net/starlark" +) + +func TestExecModule(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX-specific test") + } + + posternDB, err := db.OpenDB(&db.Config{ + DBPath: path.Join(t.TempDir(), "meta.db"), + }) + if err != nil { + t.Fatal(err) + } + + defer func(posternDB *db.DB) { + err := posternDB.Close() + if err != nil { + log.Fatal(err) + } + }(posternDB) + + policy := ` +def init(): + return None + +def test_success(): + res = exec.pipe_to_process("/bin/sh", args=["-c", "echo hello"]) + return {"code": res.code, "stdout": res.stdout, "stderr": res.stderr, "timed_out": res.timed_out} + +def test_input_piping(): + res = exec.pipe_to_process("/bin/cat", input_data="ping") + return {"code": res.code, "stdout": res.stdout, "stderr": res.stderr, "timed_out": res.timed_out} + +def test_args(): + res = exec.pipe_to_process("/bin/sh", args=["-c", "echo $1", "_", "arg1"]) + return {"code": res.code, "stdout": res.stdout, "stderr": res.stderr, "timed_out": res.timed_out} + +def test_nonzero_exit(): + res = exec.pipe_to_process("/bin/sh", args=["-c", "exit 42"]) + return {"code": res.code, "stdout": res.stdout, "stderr": res.stderr, "timed_out": res.timed_out} + +def test_stderr(): + res = exec.pipe_to_process("/bin/sh", args=["-c", "echo err >&2"]) + return {"code": res.code, "stdout": res.stdout, "stderr": res.stderr, "timed_out": res.timed_out} + +def test_environment(): + res = exec.pipe_to_process("/bin/sh", args=["-c", "echo $FOO"], environment={"FOO": "bar"}) + return {"code": res.code, "stdout": res.stdout, "stderr": res.stderr, "timed_out": res.timed_out} + +def test_env_no_leak(): + res = exec.pipe_to_process("/bin/sh", args=["-c", "echo ${HOME:-empty}"]) + return {"code": res.code, "stdout": res.stdout, "stderr": res.stderr, "timed_out": res.timed_out} + +def test_timeout(): + res = exec.pipe_to_process("/bin/sh", args=["-c", "sleep 10"], timeout=1) + return {"code": res.code, "stdout": res.stdout, "stderr": res.stderr, "timed_out": res.timed_out} + +def test_timeout_float(): + res = exec.pipe_to_process("/bin/sh", args=["-c", "sleep 1"], timeout=0.5) + return {"code": res.code, "stdout": res.stdout, "stderr": res.stderr, "timed_out": res.timed_out} + +def test_not_found(): + return exec.pipe_to_process("/nonexistent/binary/xyz") +` + + policyFile := path.Join(t.TempDir(), "policy.star") + err = os.WriteFile(policyFile, []byte(policy), 0644) + if err != nil { + t.Fatal(err) + } + + e, err := NewEngine(policyFile, posternDB) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + fnName string + wantCode int + wantOut string + wantErr string + wantTimeout bool + }{ + {"success", "test_success", 0, "hello\n", "", false}, + {"input_piping", "test_input_piping", 0, "ping", "", false}, + {"args", "test_args", 0, "arg1\n", "", false}, + {"nonzero_exit", "test_nonzero_exit", 42, "", "", false}, + {"stderr", "test_stderr", 0, "", "err\n", false}, + {"environment", "test_environment", 0, "bar\n", "", false}, + {"env_no_leak", "test_env_no_leak", 0, "empty\n", "", false}, + {"timeout", "test_timeout", -1, "", "", true}, + {"timeout_float", "test_timeout_float", -1, "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hookVal, ok := e.globals[tt.fnName] + if !ok { + t.Fatalf("%s not found in engine globals", tt.fnName) + } + + res, err := starlark.Call(e.thread, hookVal, nil, nil) + if err != nil { + t.Fatal(err) + } + + dict, ok := res.(*starlark.Dict) + if !ok { + t.Fatalf("expected dict, got %s", res.Type()) + } + + assertDictField(t, dict, "code", starlark.MakeInt(tt.wantCode)) + assertDictField(t, dict, "stdout", starlark.String(tt.wantOut)) + assertDictField(t, dict, "stderr", starlark.String(tt.wantErr)) + assertDictField(t, dict, "timed_out", starlark.Bool(tt.wantTimeout)) + }) + } + + t.Run("not_found", func(t *testing.T) { + hookVal, ok := e.globals["test_not_found"] + if !ok { + t.Fatal("test_not_found not found in engine globals") + } + _, err := starlark.Call(e.thread, hookVal, nil, nil) + if err == nil { + t.Fatal("expected error for nonexistent binary") + } + }) +} + +func assertDictField(t *testing.T, d *starlark.Dict, key string, want starlark.Value) { + t.Helper() + v, found, err := d.Get(starlark.String(key)) + if err != nil { + t.Fatalf("key %q err %q", key, err) + } + if !found { + t.Fatalf("key %q not found", key) + } + if v != want { + t.Errorf("field %q: got %v, want %v", key, v, want) + } +}
M
internal/policy/parse.go
→
internal/policy/parse.go
@@ -60,6 +60,7 @@ parsedListeners: make([]ListenerConfig, 0),
} predeclared := starlark.StringDict{ + "exec": &ExecModule{}, "http": &HTTPModule{}, "json": json.Module, "kv": NewKVModule(database),