package trust // Tests for the project-trust interactive glue (US-018, #134): the first-run // prompt, the tool-call confirmation, the summary preview, and the // BeforeToolCall gating hook. The trust store itself is covered in // manager_test.go; here we exercise the interactive pieces. import ( "bufio" "bytes" "context" "path/filepath" "strings" "sync" "testing" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/runtime" ) // newTrustManagerAt builds a Manager backed by path. func newTrustManagerAt(t *testing.T, path string) *Manager { t.Helper() m, err := NewManager(path) if err != nil { t.Fatalf("NewManager: %v", err) } return m } // newTrustManager builds a Manager backed by a fresh temp file and returns the // manager plus its path (so a test can reload to verify persistence). func newTrustManager(t *testing.T) (*Manager, string) { t.Helper() path := filepath.Join(t.TempDir(), "trust.json") return newTrustManagerAt(t, path), path } // readerOf wraps a string in the *bufio.Reader the trust prompts expect. func readerOf(s string) *bufio.Reader { return bufio.NewReader(strings.NewReader(s)) } // TestEnsureTrustPromptTrustedThisDir verifies choice 1 + scope 1 (this // directory) persists a Trusted decision for cwd. func TestEnsureTrustPromptTrustedThisDir(t *testing.T) { mgr, _ := newTrustManager(t) cwd := t.TempDir() var out bytes.Buffer EnsureTrustPrompt(&out, readerOf("1\n1\n"), mgr, cwd) if got := mgr.NearestTrustDecision(cwd); !got.Found || got.Decision != Trusted || got.Path != cwd { t.Errorf("after prompt, NearestTrustDecision(%q) = %+v, want Trusted/@cwd", cwd, got) } if !strings.Contains(out.String(), "First time in this directory") { t.Errorf("output missing prompt: %q", out.String()) } } // TestEnsureTrustPromptRejectParent verifies choice 3 (reject) + scope 2 // (parent) persists an Untrusted decision for the parent directory. func TestEnsureTrustPromptRejectParent(t *testing.T) { mgr, _ := newTrustManager(t) cwd := t.TempDir() parent := filepath.Dir(cwd) EnsureTrustPrompt(&bytes.Buffer{}, readerOf("3\n2\n"), mgr, cwd) if got := mgr.NearestTrustDecision(cwd); !got.Found || got.Decision != Untrusted || got.Path != parent { t.Errorf("after reject-parent, NearestTrustDecision(%q) = %+v, want Untrusted/@parent", cwd, got) } } // TestEnsureTrustPromptJustOnce verifies choice 2 (just once) grants session // trust without persisting: IsTrusted is true now but false after a reload. func TestEnsureTrustPromptJustOnce(t *testing.T) { mgr, path := newTrustManager(t) cwd := t.TempDir() EnsureTrustPrompt(&bytes.Buffer{}, readerOf("2\n"), mgr, cwd) if !mgr.IsTrusted(cwd) { t.Error("IsTrusted(cwd) = false after just-once, want true") } // Reload from the same file: session trust must not survive. m2 := newTrustManagerAt(t, path) if m2.IsTrusted(cwd) { t.Error("reload IsTrusted(cwd) = true, want false (session trust must not persist)") } } // TestEnsureTrustPromptSkipsWhenDecided verifies that when a decision already // exists, EnsureTrustPrompt is a no-op: it writes nothing and reads nothing. func TestEnsureTrustPromptSkipsWhenDecided(t *testing.T) { mgr, _ := newTrustManager(t) cwd := t.TempDir() if err := mgr.SetDecision(cwd, Trusted); err != nil { t.Fatalf("SetDecision: %v", err) } var out bytes.Buffer // Empty reader: if the prompt tried to read it would block/EOF; it must not. EnsureTrustPrompt(&out, readerOf(""), mgr, cwd) if out.Len() != 0 { t.Errorf("EnsureTrustPrompt wrote %q when a decision existed, want no output", out.String()) } } // TestEstablishTrustApprove verifies --approve grants session trust up front // without prompting: IsTrusted is true immediately, nothing is written, and the // (empty) reader is never consumed. Session trust must not persist across a // reload — --approve is per-run, not a saved decision. func TestEstablishTrustApprove(t *testing.T) { mgr, path := newTrustManager(t) cwd := t.TempDir() var out bytes.Buffer EstablishTrust(&out, readerOf(""), mgr, cwd, true) if !mgr.IsTrusted(cwd) { t.Error("IsTrusted(cwd) = false after --approve, want true") } if out.Len() != 0 { t.Errorf("EstablishTrust wrote %q with --approve, want no prompt", out.String()) } m2 := newTrustManagerAt(t, path) if m2.IsTrusted(cwd) { t.Error("reload IsTrusted(cwd) = true, want false (--approve must not persist)") } } // TestEstablishTrustWithoutApprove verifies that without --approve, EstablishTrust // defers to the first-launch prompt (which runs for an undecided directory). func TestEstablishTrustWithoutApprove(t *testing.T) { mgr, _ := newTrustManager(t) cwd := t.TempDir() var out bytes.Buffer // "2\n" answers the just-once prompt; if the prompt did not run, this input // would be left unread and IsTrusted would stay false. EstablishTrust(&out, readerOf("2\n"), mgr, cwd, false) if !strings.Contains(out.String(), "First time in this directory") { t.Errorf("without --approve, expected the trust prompt; got %q", out.String()) } if !mgr.IsTrusted(cwd) { t.Error("IsTrusted(cwd) = false after just-once via EstablishTrust, want true") } } // TestConfirmToolCall verifies the y/n/a responses. func TestConfirmToolCall(t *testing.T) { call := agentcore.AgentToolCall{Name: "bash", Arguments: []byte(`{"command":"ls"}`)} cases := []struct { in string allow bool always bool }{ {"y\n", true, false}, {"yes\n", true, false}, {"a\n", true, true}, {"always\n", true, true}, {"n\n", false, false}, {"\n", false, false}, {"garbage\n", false, false}, } for _, c := range cases { var out bytes.Buffer allow, always := ConfirmToolCall(&out, readerOf(c.in), call) if allow != c.allow || always != c.always { t.Errorf("ConfirmToolCall(%q) = (%v,%v), want (%v,%v)", c.in, allow, always, c.allow, c.always) } } } // TestToolCallSummary verifies the one-line preview for each side-effect tool. func TestToolCallSummary(t *testing.T) { cases := []struct { name string args string want string }{ {"bash", `{"command":"rm -rf /tmp/x"}`, "command: rm -rf /tmp/x"}, {"write", `{"path":"/a/b.txt","content":"..."}`, "path: /a/b.txt"}, {"edit", `{"path":"/a/b.txt","old_string":"x"}`, "path: /a/b.txt"}, {"bash", `{}`, ""}, {"bash", ``, ""}, {"bash", `not-json`, "not-json"}, } for _, c := range cases { got := toolCallSummary(agentcore.AgentToolCall{Name: c.name, Arguments: []byte(c.args)}) if got != c.want { t.Errorf("toolCallSummary(%s,%s) = %q, want %q", c.name, c.args, got, c.want) } } } // TestToolCallSummaryTruncates verifies a very long command is truncated. func TestToolCallSummaryTruncates(t *testing.T) { long := strings.Repeat("x", 500) got := toolCallSummary(agentcore.AgentToolCall{Name: "bash", Arguments: []byte(`{"command":"` + long + `"}`)}) if !strings.HasSuffix(got, " …") { t.Errorf("expected truncated output ending with ' …', got %q", got) } if len([]rune(got)) > 250 { t.Errorf("output not truncated: len=%d", len([]rune(got))) } } // TestTrustBeforeToolCallGating verifies the hook: nil manager -> nil hook; // trusted dir -> allow; untrusted + deny -> block; untrusted + always -> allow // and grant session trust so the next call is allowed without a prompt. func TestTrustBeforeToolCallGating(t *testing.T) { cwd := t.TempDir() mu := &sync.Mutex{} call := agentcore.AgentToolCall{Name: "write", Arguments: []byte(`{"path":"/tmp/x"}`)} // nil manager -> no hook. if h := BeforeToolCall(nil, cwd, nil, nil, mu); h != nil { t.Error("nil manager should yield nil hook") } // Trusted dir: hook returns nil (allow) without prompting. mgr, _ := newTrustManager(t) if err := mgr.SetDecision(cwd, Trusted); err != nil { t.Fatalf("SetDecision: %v", err) } hook := BeforeToolCall(mgr, cwd, readerOf(""), &bytes.Buffer{}, mu) if dec := hook(context.Background(), call); dec != nil { t.Errorf("trusted dir: hook returned %+v, want nil", dec) } // Untrusted dir, user denies: hook blocks with an error result. mgr2, _ := newTrustManager(t) var out bytes.Buffer hook2 := BeforeToolCall(mgr2, cwd, readerOf("n\n"), &out, mu) dec := hook2(context.Background(), call) if dec == nil || !dec.Block { t.Errorf("untrusted + deny: hook returned %+v, want a Block decision", dec) } // Untrusted dir, user says "always": hook allows and grants session trust, // so a second call is allowed WITHOUT reading any more input. mgr3, _ := newTrustManager(t) var out3 bytes.Buffer // Only one line of input ("a\n"); a second prompt would block on EOF. hook3 := BeforeToolCall(mgr3, cwd, readerOf("a\n"), &out3, mu) if dec := hook3(context.Background(), call); dec != nil { t.Errorf("untrusted + always: hook returned %+v, want nil (allow)", dec) } if !mgr3.IsTrusted(cwd) { t.Error("after 'always', IsTrusted(cwd) = false, want true (session trust granted)") } // Second call: no input left, but session trust means no prompt. if dec := hook3(context.Background(), call); dec != nil { t.Errorf("second call after 'always': hook returned %+v, want nil (no re-prompt)", dec) } } // TestTrustBeforeToolCallSkipsNonSideEffect verifies read-only tools are never // gated, even in an untrusted directory. func TestTrustBeforeToolCallSkipsNonSideEffect(t *testing.T) { cwd := t.TempDir() mgr, _ := newTrustManager(t) mu := &sync.Mutex{} hook := BeforeToolCall(mgr, cwd, readerOf(""), &bytes.Buffer{}, mu) for _, name := range []string{"read", "grep", "find", "todo", "webfetch"} { if dec := hook(context.Background(), agentcore.AgentToolCall{Name: name}); dec != nil { t.Errorf("non-side-effect tool %q was gated (%+v), want nil", name, dec) } } } // TestRegisterTrustCommand exercises the /trust action closure: status on an // undecided dir, on (default) persists Trusted, once grants session trust, off // revokes session trust AND persists Untrusted (the M2 regression - without // ClearSessionTrust, an active "always"/once grant would keep IsTrusted true // until restart), and an unknown arg yields usage. func TestRegisterTrustCommand(t *testing.T) { cwd := t.TempDir() mgr, _ := newTrustManager(t) reg := runtime.NewSlashRegistry() RegisterCommand(reg, mgr, cwd) cmd, ok := reg.Lookup("trust") if !ok { t.Fatal("/trust command not registered") } if got := cmd.Action("status"); !strings.Contains(got, "undecided") { t.Errorf("status on undecided dir = %q, want 'undecided'", got) } // on (default arg) persists Trusted for cwd. if got := cmd.Action(""); !strings.Contains(got, "trusted") { t.Errorf("on = %q, want 'trusted'", got) } if res := mgr.NearestTrustDecision(cwd); !res.Found || res.Decision != Trusted { t.Errorf("after on, nearest = %+v, want Trusted/@cwd", res) } // Fresh dir: once grants session trust (in-memory), off must revoke it and // persist Untrusted so IsTrusted is false immediately. cwd2 := t.TempDir() mgr2, _ := newTrustManager(t) reg2 := runtime.NewSlashRegistry() RegisterCommand(reg2, mgr2, cwd2) cmd2, _ := reg2.Lookup("trust") cmd2.Action("once") if !mgr2.IsTrusted(cwd2) { t.Error("after once, IsTrusted = false, want true") } if got := cmd2.Action("off"); !strings.Contains(got, "untrusted") { t.Errorf("off = %q, want 'untrusted'", got) } if mgr2.IsTrusted(cwd2) { t.Error("after off, IsTrusted = true, want false (session grant must be revoked)") } if res := mgr2.NearestTrustDecision(cwd2); !res.Found || res.Decision != Untrusted { t.Errorf("after off, nearest = %+v, want Untrusted/@cwd", res) } // Unknown arg yields usage text. if got := cmd.Action("bogus"); !strings.Contains(got, "usage") { t.Errorf("unknown arg = %q, want 'usage'", got) } }