first commit
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user