Files
BlackBean/pigo/internal/cli/run/toolpolicy_setup_test.go
T
2026-08-14 23:41:57 +08:00

213 lines
8.0 KiB
Go

package run
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// writePolicySkill drops a minimal valid skill into dir so LoadSkills has
// something to advertise.
func writePolicySkill(t *testing.T, dir, name, description string) {
t.Helper()
body := "---\nname: " + name + "\ndescription: " + description + "\n---\nDo the thing."
if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte(body), 0o644); err != nil {
t.Fatalf("write skill %s: %v", name, err)
}
}
// setupToolNames runs SetupEnv with a policy and returns the resulting tool
// names. The provider is never contacted, so a stub model id is fine; --no-skills
// keeps the run independent of the machine's skills directory.
func setupToolNames(t *testing.T, policy ToolPolicy) []string {
t.Helper()
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("PIGO_HOME", t.TempDir()) // isolate plugin/skill discovery
env, err := SetupEnv("openrouter/free", "", "", "", "", false /*noTools*/, true /*noSkills*/, "", nil, false /*memEnabled*/, policy)
if err != nil {
t.Fatalf("SetupEnv: %v", err)
}
return names(env.Tools)
}
// contains reports whether name is in the set.
func contains(set []string, name string) bool {
for _, n := range set {
if n == name {
return true
}
}
return false
}
// TestSetupEnvAppliesAllowList confirms a whitelist narrows the advertised set.
// The `task` tool is expected to survive only when explicitly allowed.
func TestSetupEnvAppliesAllowList(t *testing.T) {
got := setupToolNames(t, NewToolPolicy([]string{"read,grep"}, nil))
want := []string{"read", "grep"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("tool set = %q, want exactly %q", got, want)
}
}
// TestSetupEnvAppliesDenyList confirms a blacklist removes the named tools while
// leaving everything else — including the side-effect tools not named — in place.
func TestSetupEnvAppliesDenyList(t *testing.T) {
got := setupToolNames(t, NewToolPolicy(nil, []string{"bash", "bash_output", "kill_bash"}))
for _, denied := range []string{"bash", "bash_output", "kill_bash"} {
if contains(got, denied) {
t.Errorf("%q survived the deny list: %q", denied, got)
}
}
for _, kept := range []string{"read", "write", "edit", "grep"} {
if !contains(got, kept) {
t.Errorf("%q was removed but was not denied: %q", kept, got)
}
}
}
// TestSetupEnvDenyWinsOverAllow is the fail-closed guarantee: a tool named on
// both sides is removed.
func TestSetupEnvDenyWinsOverAllow(t *testing.T) {
got := setupToolNames(t, NewToolPolicy([]string{"read", "bash"}, []string{"bash"}))
if contains(got, "bash") {
t.Errorf("bash was on both lists and must be removed, got %q", got)
}
if !contains(got, "read") {
t.Errorf("read was allowed and not denied, so it must survive, got %q", got)
}
}
// TestSetupEnvUnconstrainedIsUnchanged is the zero-regression check: no policy
// means the full built-in set, including the side-effect tools.
func TestSetupEnvUnconstrainedIsUnchanged(t *testing.T) {
got := setupToolNames(t, ToolPolicy{})
for _, want := range []string{"read", "write", "edit", "grep", "find", "bash", "todo", "webfetch", "websearch", "task"} {
if !contains(got, want) {
t.Errorf("unconstrained run is missing %q: %q", want, got)
}
}
}
// TestSetupEnvRejectsUnknownToolName confirms a typo aborts setup with a
// ToolPolicyError, which is what maps to exit code 2 rather than a run that
// silently ignores the boundary.
func TestSetupEnvRejectsUnknownToolName(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("PIGO_HOME", t.TempDir())
_, err := SetupEnv("openrouter/free", "", "", "", "", false, true, "", nil, false, NewToolPolicy([]string{"raed"}, nil))
if err == nil {
t.Fatal("SetupEnv = nil error, want a failure for the misspelled tool name")
}
var policyErr *ToolPolicyError
if !errors.As(err, &policyErr) {
t.Fatalf("error type = %T, want *ToolPolicyError", err)
}
}
// TestChildToolSetInheritsPolicy closes the sub-agent escape hatch: a child
// dispatched by the task tool must not regain a tool the parent's policy removed,
// or `--disallowed-tools bash` would be bypassable by delegating.
func TestChildToolSetInheritsPolicy(t *testing.T) {
child := names(ChildToolSet("/tmp", NewToolPolicy(nil, []string{"bash"})))
if contains(child, "bash") {
t.Errorf("child regained the denied bash tool: %q", child)
}
if contains(child, "task") {
t.Errorf("child must not contain task (nesting guard): %q", child)
}
if !contains(child, "read") {
t.Errorf("child lost an un-denied tool: %q", child)
}
allowOnly := names(ChildToolSet("/tmp", NewToolPolicy([]string{"read"}, nil)))
if strings.Join(allowOnly, ",") != "read" {
t.Errorf("child under an allow list = %q, want exactly [read]", allowOnly)
}
// With no policy the child is the plain nesting-guarded builtin set.
unconstrained := names(ChildToolSet("/tmp", ToolPolicy{}))
if !contains(unconstrained, "bash") || contains(unconstrained, "task") {
t.Errorf("unconstrained child set = %q, want builtins minus task", unconstrained)
}
}
// TestSetupEnvSkillsGatedOnFilteredReadTool covers the ordering dependency: the
// <available_skills> block is advertised only when `read` survives the policy,
// because the model needs read to load a skill body. Filtering must therefore
// happen before the system prompt is built.
func TestSetupEnvSkillsGatedOnFilteredReadTool(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("PIGO_HOME", t.TempDir())
skillsDir := t.TempDir()
t.Setenv("PIGO_SKILLS_DIR", skillsDir)
writePolicySkill(t, skillsDir, "weather", "get the weather")
withRead, err := SetupEnv("openrouter/free", "", "", "", "", false, false, "", nil, false, ToolPolicy{})
if err != nil {
t.Fatalf("SetupEnv (unconstrained): %v", err)
}
if !strings.Contains(withRead.SysPrompt, "<available_skills>") {
t.Fatal("unconstrained run must advertise skills; the fixture or gate is wrong")
}
withoutRead, err := SetupEnv("openrouter/free", "", "", "", "", false, false, "", nil, false, NewToolPolicy(nil, []string{"read"}))
if err != nil {
t.Fatalf("SetupEnv (read denied): %v", err)
}
if strings.Contains(withoutRead.SysPrompt, "available_skills") {
t.Error("denying read must suppress <available_skills>: the model could not load a skill body")
}
}
// captureStderr runs fn with os.Stderr redirected to a pipe and returns whatever
// was written. It is not safe under t.Parallel — these tests must stay serial.
func captureStderr(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
orig := os.Stderr
os.Stderr = w
defer func() { os.Stderr = orig }()
fn()
w.Close()
out, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read captured stderr: %v", err)
}
return string(out)
}
// TestSetupEnvNoToolsWithPolicyWarns is the counterpart to the typo guarantee:
// under --no-tools the set is empty, so ValidateToolPolicy cannot flag a
// misspelled name. Rather than let the boundary silently vanish, SetupEnv must
// still succeed but print a warning that the policy is inert — otherwise a user
// combining --no-tools with a (possibly misspelled) --allowed-tools would
// believe a boundary is in force when none is.
func TestSetupEnvNoToolsWithPolicyWarns(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("PIGO_HOME", t.TempDir())
var env Env
var err error
stderr := captureStderr(t, func() {
// A deliberately misspelled name: with tools present this would abort with
// exit code 2, but --no-tools skips validation, so it must not error.
env, err = SetupEnv("openrouter/free", "", "", "", "", true /*noTools*/, true /*noSkills*/, "", nil, false, NewToolPolicy([]string{"raed"}, nil))
})
if err != nil {
t.Fatalf("SetupEnv(--no-tools + policy) = %v, want nil (validation is skipped, not failed)", err)
}
if len(env.Tools) != 0 {
t.Errorf("--no-tools must leave no tools, got %q", names(env.Tools))
}
if !strings.Contains(stderr, "--no-tools disables all tools") {
t.Errorf("expected an inert-policy warning on stderr, got %q", stderr)
}
}