Download
203 lines 7.4 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
package tools

import (
	"encoding/json"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"blinker/internal/policy"
)

func allow(names ...string) []policy.ToolRule {
	var rules []policy.ToolRule
	for _, n := range names {
		rules = append(rules, policy.ToolRule{Tool: n, Access: policy.Allow})
	}
	return rules
}

// echoTool prints its name and arguments, one per line.
const echoTool = `#!/bin/bash
# blinker-tool
# description: Print the arguments.
# arg text string: Text.
# arg count integer: A number.
# arg loud boolean: Whether to shout.
# arg color enum(red|blue): A color.
# arg files path...: Files.
printf '%s\n' "$0" "$@"
`

func TestUserToolArguments(t *testing.T) {
	f := setup(t, map[string]string{"Echo": echoTool}, allow("Echo")...)
	r := f.run(t, "Echo", map[string]any{
		"text":  `$(touch pwned); echo "injected"`,
		"count": 3,
		"loud":  true,
		"color": "red",
		"files": []string{"main.go", "-rf"},
	})
	want := strings.Join([]string{"Echo", `$(touch pwned); echo "injected"`, "3", "true", "red",
		filepath.Join(f.project, "main.go"), filepath.Join(f.project, "-rf")}, "\n")
	if r.Failed || r.Output != want {
		t.Errorf("output:\n%s\nwant:\n%s", r.Output, want)
	}
	if _, err := os.Stat(filepath.Join(f.project, "pwned")); err == nil {
		t.Error("an argument ran as shell code")
	}
	ok := map[string]any{"text": "", "count": 1, "loud": false, "color": "red"}
	for name, change := range map[string]map[string]any{
		"missing":       {"text": nil},
		"unknown":       {"extra": 1},
		"not integer":   {"count": "3"},
		"not boolean":   {"loud": "yes"},
		"bad enum":      {"color": "green"},
		"forbidden":     {"files": []string{".env"}},
		"not a list":    {"files": "main.go"},
		"outside reach": {"files": []string{"~/other/secret.txt"}},
		// null would otherwise reach the script as 0, false or "".
		"null integer": {"count": json.RawMessage("null")},
		"null boolean": {"loud": json.RawMessage("null")},
		"null string":  {"text": json.RawMessage("null")},
		"null in list": {"files": []any{nil}},
	} {
		args := map[string]any{}
		for k, v := range ok {
			args[k] = v
		}
		for k, v := range change {
			if v == nil {
				delete(args, k)
			} else {
				args[k] = v
			}
		}
		if r := f.run(t, "Echo", args); !r.Failed || strings.Contains(r.Output, "Echo\n") {
			t.Errorf("%s: ran: %+v", name, r)
		}
	}
}

func TestUserToolGrants(t *testing.T) {
	f := setup(t, map[string]string{"MarkGit": "# blinker-tool\n# description: Write a marker into .git.\n# write: .git\necho x > .git/marker\n"}, allow("MarkGit")...)
	// Creating .git itself would need the project writable; the grant is
	// for what is inside it.
	if err := os.Mkdir(filepath.Join(f.project, ".git"), 0o755); err != nil {
		t.Fatal(err)
	}
	if r := f.run(t, "Safe_Bash", map[string]any{"script": "echo x > .git/marker"}); !r.Failed {
		t.Fatalf("Safe_Bash wrote .git: %+v", r)
	}
	if r := f.run(t, "MarkGit", map[string]any{}); r.Failed {
		t.Fatalf("the tool's grant did not apply: %+v", r)
	}
	if _, err := os.Stat(filepath.Join(f.project, ".git", "marker")); err != nil {
		t.Error(err)
	}
	if r := f.run(t, "Safe_Bash", map[string]any{"script": "echo y > .git/marker"}); !r.Failed {
		t.Errorf("the tool's grant leaked to Safe_Bash: %+v", r)
	}
}

func TestUserToolAccess(t *testing.T) {
	tool := "# blinker-tool\n# description: d\necho ran\n"
	f := setup(t, map[string]string{"Asks": tool, "Denied": tool, "Unlisted": tool},
		policy.ToolRule{Tool: "Asks", Access: policy.Ask}, policy.ToolRule{Tool: "Denied", Access: policy.Deny})
	names := map[string]bool{}
	for _, s := range f.tb.Specs() {
		names[s.Name] = true
	}
	if !names["Asks"] || names["Denied"] || names["Unlisted"] {
		t.Errorf("tool list: %v", names)
	}
	for _, name := range []string{"Denied", "Unlisted"} {
		if r := f.run(t, name, map[string]any{}); !r.Failed || strings.Contains(r.Output, "ran") {
			t.Errorf("%s ran: %+v", name, r)
		}
	}
	f.answer = func(q Question) Answer { return Answer{Option: q.Opts[1]} } // deny
	if r := f.run(t, "Asks", map[string]any{}); !r.Failed || strings.Contains(r.Output, "ran") || len(f.asked) != 1 {
		t.Errorf("denied approval: %+v", r)
	}
	if q := f.asked[0]; q.Body != "" || q.About != "" {
		t.Errorf("the approval of a call with no arguments shows more than its title: %+v", q)
	}
	f.answer = func(q Question) Answer { return Answer{Option: q.Opts[0]} }
	if r := f.run(t, "Asks", map[string]any{}); r.Failed || r.Output != "ran" {
		t.Errorf("approved: %+v", r)
	}
}

func TestBuiltinTools(t *testing.T) {
	f := setup(t, nil, allow("Define", "Usage", "Commit", "Fetch")...)
	if err := os.WriteFile(filepath.Join(f.project, "lib.go"), []byte("package main\n\nfunc helper() int { return 1 }\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(f.project, "main.go"), []byte("package main\n\nfunc main() { println(helper()) }\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	r := f.run(t, "Usage", map[string]any{"name": "helper"})
	if r.Failed || !strings.Contains(r.Output, "main.go:3:") || !strings.Contains(r.Output, "lib.go:3:") {
		t.Errorf("Usage: %+v", r)
	}
	if _, err := exec.LookPath("ctags"); err == nil {
		r = f.run(t, "Define", map[string]any{"name": "helper"})
		if r.Failed || !strings.Contains(r.Output, "lib.go:3: func") || strings.Contains(r.Output, "main.go") {
			t.Errorf("Define: %+v", r)
		}
	}
	// Fetch takes only https URLs, and reaches only its own hosts.
	r = f.run(t, "Fetch", map[string]any{"url": "file:///etc/passwd"})
	if !r.Failed || !strings.Contains(r.Output, "https URLs only") {
		t.Errorf("Fetch of a file: %+v", r)
	}
	r = f.run(t, "Fetch", map[string]any{"url": "https://example.com/"})
	if !r.Failed || len(f.asked) != 0 || !strings.Contains(r.Output, "proxy denied example.com") {
		t.Errorf("Fetch of another host: %+v", r)
	}
	if _, err := exec.LookPath("git"); err != nil {
		return
	}
	// A repository, with a hook that must not run. The user makes it: no
	// command may write a repository's configuration or hooks.
	for _, args := range [][]string{
		{"init", "-q"},
		{"config", "user.email", "t@example.com"},
		{"config", "user.name", "Test"},
	} {
		if out, err := exec.Command("git", append([]string{"-C", f.project}, args...)...).CombinedOutput(); err != nil {
			t.Fatalf("git %s: %v: %s", args, err, out)
		}
	}
	if err := os.WriteFile(filepath.Join(f.project, ".git", "hooks", "pre-commit"), []byte("#!/bin/sh\ntouch hook-ran\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	r = f.run(t, "Commit", map[string]any{"message": "Add lib", "files": []string{"lib.go"}})
	if r.Failed || !strings.Contains(r.Output, "Add lib") || strings.Contains(r.Output, "approved") {
		t.Fatalf("Commit: %+v", r)
	}
	if _, err := os.Stat(filepath.Join(f.project, "hook-ran")); err == nil {
		t.Error("a git hook ran")
	}
	out, err := exec.Command("git", "-C", f.project, "show", "--name-only", "--format=%s", "HEAD").Output()
	if err != nil || strings.TrimSpace(string(out)) != "Add lib\n\nlib.go" {
		t.Errorf("the commit: %q, %v", out, err)
	}
}

func TestArgTable(t *testing.T) {
	got := argTable([]string{"url", "part", "files", "files"}, []string{"https://example.com/a b", "1", "", " x"})
	want := "url    https://example.com/a b\npart   1\nfiles  \"\"\nfiles  \" x\""
	if got != want {
		t.Errorf("argTable = %q, want %q", got, want)
	}
	// A line break or an invisible character cannot pass for layout.
	for _, v := range []string{"a\npart   2", "a\tb", "a\u202eb", "a\x1b[2Jb"} {
		if got := argTable([]string{"x"}, []string{v}); strings.ContainsAny(got, "\n\t\u202e\x1b") {
			t.Errorf("argTable(%q) = %q", v, got)
		}
	}
}