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)
}
}
}
|