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 // 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, "") { 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 : 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) } }