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
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,28 @@ jobs:
- name: go test
run: go test -race ./...

windows-test:
name: Windows Owner Suite
runs-on: windows-2025
permissions:
contents: read
steps:
- uses: actions/checkout@v7

- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod

- name: Configure git for private modules
run: git config --global url."https://${{ secrets.RELEASES_TOKEN }}@github.com/".insteadOf "https://github.com/"

- name: go vet
run: go vet ./...

- name: go test
run: go test -race -p=1 ./...

validate-contracts:
runs-on: ubuntu-latest
permissions:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- README verified-status banner per workflow#714 (multi-repo QoL sweep).
- CONTRIBUTING.md, examples/minimal/config.yaml, and GitHub issue/PR templates.

### Fixed
- Normalize native Windows separators for policy glob matching, including deny rules used by the executor.
- Validate Linux container mount destinations independently of host path conventions, preserving critical-directory denials.
- Correct platform-specific owner-suite fixtures and assertions while retaining the full Windows and Linux test suites.

## [v0.9.3] - 2026-05-19

Initial CHANGELOG entry tracking the current release. See git tags for prior versions.
114 changes: 114 additions & 0 deletions executor/policy_path_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package executor

import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/GoCodeAlone/workflow-plugin-agent/policy"
"github.com/GoCodeAlone/workflow-plugin-agent/provider"
"github.com/GoCodeAlone/workflow-plugin-agent/tools"
)

func TestExecute_PolicyBridgeNativePathDeny(t *testing.T) {
home := t.TempDir()
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", home)
} else {
t.Setenv("HOME", home)
}
if err := os.Mkdir(filepath.Join(home, ".ssh"), 0o700); err != nil {
t.Fatal(err)
}
deniedPath := filepath.Join(home, ".ssh", "id_rsa")
allowedPath := filepath.Join(home, "notes.txt")
if err := os.WriteFile(deniedPath, []byte("denied fixture"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(allowedPath, []byte("allowed sentinel"), 0o600); err != nil {
t.Fatal(err)
}

// The provider supplies tool calls, and the tool records real file reads.
// Policy, its bridge, the executor, and registry run without substitutes.
var invokedPaths []string
reg := tools.NewRegistry()
reg.Register(&simpleTool{
name: "file_read",
def: provider.ToolDef{
Name: "file_read",
Description: "read a fixture file",
},
fn: func(_ context.Context, args map[string]any) (any, error) {
path := args["path"].(string)
invokedPaths = append(invokedPaths, path)
data, err := os.ReadFile(path)
return string(data), err
},
})
te := policy.NewTrustEngine("custom", []policy.TrustRule{
{Pattern: "file_read", Action: policy.Allow},
{Pattern: "path:**", Action: policy.Allow},
{Pattern: "path:~/.ssh/*", Action: policy.Deny},
}, nil)
chatCalls := 0
p := &callCountProvider{
onChat: func() (*provider.Response, error) {
chatCalls++
if chatCalls == 1 {
return &provider.Response{ToolCalls: []provider.ToolCall{
{ID: "native-denied", Name: "file_read", Arguments: map[string]any{"path": deniedPath}},
{ID: "native-allowed", Name: "file_read", Arguments: map[string]any{"path": allowedPath}},
}}, nil
}
return &provider.Response{Content: "done"}, nil
},
}
var events []Event
result, err := Execute(context.Background(), Config{
Provider: p,
ToolRegistry: reg,
TrustEngine: NewPolicyTrustBridge(te),
MaxIterations: 2,
OnEvent: func(e Event) {
events = append(events, e)
},
}, "read the allowed fixture", "check native path trust", "path-regression")
if err != nil {
t.Fatalf("Execute: %v", err)
}
if result.Status != "completed" || chatCalls != 2 {
t.Errorf("executor completion: status %q, provider calls %d; want completed after 2 calls", result.Status, chatCalls)
}
if len(invokedPaths) != 1 || invokedPaths[0] != allowedPath {
t.Errorf("file_read invoked for %q, want only allowed path %q; denied native path %q must never execute", invokedPaths, allowedPath, deniedPath)
}

denyEvents, allowEvents := 0, 0
toolResults := make(map[string]Event)
for _, e := range events {
switch e.Type {
case EventTrustDeny:
if e.ToolName != "file_read" {
t.Errorf("trust deny tool = %q, want file_read", e.ToolName)
}
denyEvents++
case EventTrustAllow:
allowEvents++
case EventToolCallResult:
toolResults[e.ToolCallID] = e
}
}
if denyEvents != 1 || allowEvents != 1 {
t.Errorf("trust events: deny %d, allow %d; want one deny and one allowed control", denyEvents, allowEvents)
}
if e, ok := toolResults["native-denied"]; !ok || !e.ToolError || !strings.Contains(e.ToolResult, "denied by trust policy") {
t.Errorf("denied tool result = %+v (present %v), want trust-policy error", e, ok)
}
if e, ok := toolResults["native-allowed"]; !ok || e.ToolError || e.ToolResult != `"allowed sentinel"` {
t.Errorf("allowed tool result = %+v (present %v), want actual fixture contents", e, ok)
}
}
12 changes: 8 additions & 4 deletions genkit/pty_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,21 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"

"github.com/GoCodeAlone/workflow-plugin-agent/provider"
)

// mockCLIAdapter is a test adapter that drives a shell script mock CLI.
// mockCLIAdapter is a test adapter that drives a compiled mock CLI.
type mockCLIAdapter struct {
name string
binary string
workDir string
}

func (m *mockCLIAdapter) Name() string { return m.name }
func (m *mockCLIAdapter) Name() string { return m.name }
func (m *mockCLIAdapter) Binary() string { return m.binary }

func (m *mockCLIAdapter) NonInteractiveArgs(msg string) []string {
Expand Down Expand Up @@ -82,6 +83,9 @@ func main() {
dir := t.TempDir()
srcFile := filepath.Join(dir, "mock_cli.go")
binFile := filepath.Join(dir, "mock_cli")
if runtime.GOOS == "windows" {
binFile += ".exe"
}

if err := os.WriteFile(srcFile, []byte(src), 0644); err != nil {
t.Fatalf("write mock CLI src: %v", err)
Expand Down Expand Up @@ -122,8 +126,8 @@ func TestPTYProviderChat(t *testing.T) {
if resp == nil {
t.Fatal("Chat() returned nil response")
}
if resp.Content == "" {
t.Error("Chat() returned empty content")
if want := "RESPONSE: hello\n"; resp.Content != want {
t.Errorf("Chat() content = %q, want %q", resp.Content, want)
}
}

Expand Down
9 changes: 5 additions & 4 deletions orchestrator/container_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"log"
"path"
"path/filepath"
"strconv"
"strings"
Expand Down Expand Up @@ -483,16 +484,16 @@ func parseMemoryString(s string) (int64, error) {
return n, nil
}

// validateMountPaths checks that a bind mount source and target are safe.
// Source must be an absolute path. Target must not be a critical system directory.
// validateMountPaths requires a native absolute host source and an absolute Linux
// container target. The cleaned target must not equal a listed critical directory.
func validateMountPaths(source, target string) error {
if !filepath.IsAbs(source) {
return fmt.Errorf("mount source must be an absolute path: %q", source)
}
if !filepath.IsAbs(target) {
if !path.IsAbs(target) {
return fmt.Errorf("mount target must be an absolute path: %q", target)
}
cleanTarget := filepath.Clean(target)
cleanTarget := path.Clean(target)
for _, critical := range []string{"/", "/etc", "/bin", "/usr", "/lib", "/lib64", "/sbin", "/sys", "/proc", "/dev"} {
if cleanTarget == critical {
return fmt.Errorf("mount target %q is a critical system path", cleanTarget)
Expand Down
43 changes: 39 additions & 4 deletions orchestrator/container_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,8 @@ func TestContainerManager_EnsureContainerPullDoesNotStarveStatus(t *testing.T) {
}

func TestContainerManagerDockerLifecycleOptions(t *testing.T) {
workspaceDir := t.TempDir()
cacheDir := t.TempDir()
client := &recordingDockerClient{
imageInspectErr: errors.New("image not found"),
containerCreateResponse: client.ContainerCreateResult{ID: "container-1"},
Expand All @@ -387,13 +389,13 @@ func TestContainerManagerDockerLifecycleOptions(t *testing.T) {
available: true,
}

id, err := cm.EnsureContainer(t.Context(), "project-1", "/tmp/workspace", executor.SandboxConfig{
id, err := cm.EnsureContainer(t.Context(), "project-1", workspaceDir, executor.SandboxConfig{
Image: "alpine:3.22",
Memory: "512m",
CPU: 1.5,
Network: "bridge",
Mounts: []executor.SandboxMount{
{Src: "/tmp/cache", Dst: "/cache", ReadOnly: true},
{Src: cacheDir, Dst: "/cache", ReadOnly: true},
},
})
if err != nil {
Expand All @@ -420,8 +422,8 @@ func TestContainerManagerDockerLifecycleOptions(t *testing.T) {
t.Errorf("host config resources = %#v", hostConfig.Resources)
}
if len(hostConfig.Mounts) != 2 ||
hostConfig.Mounts[0].Source != "/tmp/workspace" ||
hostConfig.Mounts[1].Source != "/tmp/cache" ||
hostConfig.Mounts[0].Source != workspaceDir ||
hostConfig.Mounts[1].Source != cacheDir ||
hostConfig.Mounts[1].Target != "/cache" ||
!hostConfig.Mounts[1].ReadOnly {
t.Errorf("host config mounts = %#v", hostConfig.Mounts)
Expand Down Expand Up @@ -474,6 +476,39 @@ func TestContainerManagerDockerLifecycleOptions(t *testing.T) {
}
}

func TestValidateMountPathsNativeHostAndLinuxTarget(t *testing.T) {
source := t.TempDir()
cases := []struct {
name, source, target, wantError string
}{
{"native host and Linux container", source, "/cache", ""},
{"normalized noncritical target", source, "/cache/./nested/..", ""},
{"relative source", "relative-cache", "/cache", "mount source must be an absolute path"},
{"relative target", source, "cache", "mount target must be an absolute path"},
{"empty target", source, "", "mount target must be an absolute path"},
{"Windows host path is not a Linux target", source, `C:\cache`, "mount target must be an absolute path"},
{"cleaned critical target", source, "/cache/../etc", "critical system path"},
{"repeated slash critical target", source, "//etc", "critical system path"},
}
for _, target := range []string{"/", "/etc", "/bin", "/usr", "/lib", "/lib64", "/sbin", "/sys", "/proc", "/dev"} {
cases = append(cases, struct{ name, source, target, wantError string }{
"critical " + target, source, target, "critical system path",
})
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateMountPaths(tc.source, tc.target)
if tc.wantError == "" {
if err != nil {
t.Fatalf("valid native source/Linux target rejected: %v", err)
}
} else if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Errorf("validateMountPaths(%q, %q) = %v, want %q", tc.source, tc.target, err, tc.wantError)
}
})
}
}

func TestContainerManagerDockerErrors(t *testing.T) {
t.Run("image pull", func(t *testing.T) {
client := &recordingDockerClient{
Expand Down
5 changes: 3 additions & 2 deletions orchestrator/provider_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,9 @@ func TestProviderRegistryTestConnection(t *testing.T) {
if msg != "connection successful" {
t.Errorf("expected 'connection successful', got %q", msg)
}
if latency <= 0 {
t.Error("expected positive latency")
// An immediate mock call can complete within one Windows clock tick.
if latency < 0 {
t.Errorf("expected nonnegative latency, got %v", latency)
}
}

Expand Down
32 changes: 25 additions & 7 deletions orchestrator/tools/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,17 +377,35 @@ func TestShellExecTool_Execute(t *testing.T) {
})

t.Run("command runs in workspace dir", func(t *testing.T) {
result, err := tool.Execute(ctx, map[string]any{"command": "pwd"})
const marker = "workspace-marker\n"
if err := os.WriteFile(filepath.Join(ws, "cwd-marker.txt"), []byte(marker), 0600); err != nil {
t.Fatal(err)
}
// A relative read proves the real shell's working directory without
// interpreting Git Bash's /c/... output as a native Windows path.
result, err := tool.Execute(ctx, map[string]any{"command": "cat cwd-marker.txt"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := result.(map[string]any)
stdout := m["stdout"].(string)
// resolve symlinks for comparison (temp dirs may be symlinked)
resolvedWs, _ := filepath.EvalSymlinks(ws)
resolvedStdout, _ := filepath.EvalSymlinks(stdout[:len(stdout)-1]) // strip trailing newline
if resolvedStdout != resolvedWs {
t.Errorf("expected working dir %q, got %q", resolvedWs, resolvedStdout)
if m["stdout"] != marker || m["exit_code"] != 0 || m["stderr"] != "" {
t.Errorf("workspace relative read = %#v, want marker and successful exit", m)
}
})

t.Run("context overrides workspace dir", func(t *testing.T) {
overrideDir := t.TempDir()
const marker = "context-workspace-marker\n"
if err := os.WriteFile(filepath.Join(overrideDir, "cwd-marker.txt"), []byte(marker), 0600); err != nil {
t.Fatal(err)
}
result, err := tool.Execute(WithWorkspacePath(ctx, overrideDir), map[string]any{"command": "cat cwd-marker.txt"})
if err != nil {
t.Fatal(err)
}
m := result.(map[string]any)
if m["stdout"] != marker || m["exit_code"] != 0 || m["stderr"] != "" {
t.Errorf("context workspace relative read = %#v, want override marker and successful exit", m)
}
})
}
Expand Down
5 changes: 3 additions & 2 deletions orchestrator/vault_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ func vaultConfigDir() string {

// deriveKey derives a 32-byte AES key from the config directory path and a
// machine-local keyfile. The keyfile is created on first use and stored at
// dir/.vault-key with 0600 permissions. This ensures the token can only be
// decrypted on the same machine in the same data directory.
// dir/.vault-key with mode 0600 on Unix; Windows inherits directory ACLs.
// The keyfile and directory must be protected by the caller. Key material and
// the directory path determine the encryption key.
func deriveKey(dir string) ([]byte, error) {
keyPath := filepath.Join(dir, ".vault-key")
keyMaterial, err := os.ReadFile(keyPath)
Expand Down
Loading
Loading