first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+319
View File
@@ -0,0 +1,319 @@
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)
}
}