picoclaw/pkg/tools/tirith_test.go
2026-05-15 14:23:09 +05:30

521 lines
15 KiB
Go

package tools
import (
"bytes"
"context"
"encoding/json"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestNewExecTool_DefaultTirithDisabled(t *testing.T) {
tool, err := NewExecTool("", false)
require.NoError(t, err)
require.False(t, tool.tirithConfig.Enabled)
require.Equal(t, "tirith", tool.tirithConfig.BinPath)
require.Equal(t, 5, tool.tirithConfig.Timeout)
require.True(t, tool.tirithConfig.FailOpen)
}
func TestTirithGuard_DisabledDoesNotResolve(t *testing.T) {
cfg := TirithConfig{
Enabled: false,
BinPath: filepath.Join(t.TempDir(), "missing-tirith"),
Timeout: 5,
FailOpen: false,
}
require.Empty(t, tirithGuard(context.Background(), "echo hello", "", cfg))
}
func TestTirithGuard_MissingBinaryFailOpenAndFailClosed(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing-tirith")
failOpen := tirithGuard(context.Background(), "echo hello", "", TirithConfig{
Enabled: true,
BinPath: missing,
Timeout: 5,
FailOpen: true,
})
require.Empty(t, failOpen)
failClosed := tirithGuard(context.Background(), "echo hello", "", TirithConfig{
Enabled: true,
BinPath: missing,
Timeout: 5,
FailOpen: false,
})
require.Contains(t, failClosed, "fail-closed")
}
func TestResolveTirithPath_NormalizationAndValidation(t *testing.T) {
require.Equal(t, "tirith", normalizeTirithBin(""))
require.Equal(t, "tirith", normalizeTirithBin(" "))
dir := t.TempDir()
_, _, err := resolveTirithPath(dir)
require.Error(t, err)
require.Contains(t, err.Error(), "directory")
home := t.TempDir()
t.Setenv("HOME", home)
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", home)
}
homeBin := filepath.Join(home, "tirith")
writeExecutableFile(t, homeBin, fakeTirithScript(`printf '{"findings":[]}'`+"\nexit 0\n"))
resolved, cacheKey, err := resolveTirithPath("~/tirith")
require.NoError(t, err)
require.Equal(t, filepath.Clean(homeBin), resolved)
require.Equal(t, filepath.Clean(homeBin), cacheKey)
if runtime.GOOS != "windows" {
notExecutable := filepath.Join(t.TempDir(), "tirith")
require.NoError(t, os.WriteFile(notExecutable, []byte("#!/bin/sh\nexit 0\n"), 0o644))
_, _, err = resolveTirithPath(notExecutable)
require.Error(t, err)
require.Contains(t, err.Error(), "not executable")
}
}
func TestResolveTirithPath_PathLookup(t *testing.T) {
skipWindowsProcessTest(t)
dir := t.TempDir()
bin := filepath.Join(dir, "tirith")
writeExecutableFile(t, bin, fakeTirithScript(`printf '{"findings":[]}'`+"\nexit 0\n"))
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
resolved, cacheKey, err := resolveTirithPath("tirith")
require.NoError(t, err)
require.Equal(t, bin, resolved)
require.Equal(t, "tirith", cacheKey)
}
func TestResolveTirithPath_ExplicitRelativePathDoesNotUsePath(t *testing.T) {
dir := t.TempDir()
oldWD, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldWD))
})
require.NoError(t, os.Chdir(dir))
local := filepath.Join(dir, "tirith")
writeExecutableFile(t, local, fakeTirithScript(`printf '{"findings":[]}'`+"\nexit 0\n"))
pathDir := t.TempDir()
pathBin := filepath.Join(pathDir, "tirith")
writeExecutableFile(t, pathBin, fakeTirithScript("exit 1\n"))
t.Setenv("PATH", pathDir+string(os.PathListSeparator)+os.Getenv("PATH"))
resolved, cacheKey, err := resolveTirithPath("./tirith")
require.NoError(t, err)
expected, err := filepath.Abs("tirith")
require.NoError(t, err)
require.Equal(t, expected, resolved)
require.Equal(t, expected, cacheKey)
}
func TestTirithGuard_DoesNotCacheFailedResolution(t *testing.T) {
skipWindowsProcessTest(t)
bin := filepath.Join(t.TempDir(), "tirith")
cfg := TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: true}
require.Empty(t, tirithGuard(context.Background(), "echo first", "", cfg))
writeExecutableFile(
t,
bin,
fakeTirithScript(`printf '{"findings":[{"severity":"HIGH","title":"later install"}]}'`+"\nexit 1\n"),
)
got := tirithGuard(context.Background(), "echo second", "", cfg)
require.Contains(t, got, "Command blocked by Tirith")
require.Contains(t, got, "later install")
}
func TestTirithGuard_Argv(t *testing.T) {
skipWindowsProcessTest(t)
argsFile := filepath.Join(t.TempDir(), "args.txt")
bin := filepath.Join(t.TempDir(), "tirith")
writeExecutableFile(t, bin, fakeTirithScript(`
printf '%s\n' "$@" > "$TIRITH_ARGS_FILE"
printf '{"findings":[]}'
exit 0
`))
t.Setenv("TIRITH_ARGS_FILE", argsFile)
got := tirithGuard(
context.Background(),
"echo hello",
"",
TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: false},
)
require.Empty(t, got)
argsData, err := os.ReadFile(argsFile)
require.NoError(t, err)
args := strings.Split(strings.TrimSpace(string(argsData)), "\n")
require.Equal(t, []string{
"check",
"--format",
"json",
"--non-interactive",
"--no-daemon",
"--shell",
"posix",
"--",
"echo hello",
}, args)
}
func TestTirithGuard_UsesLocalOnlyPolicyEnvironment(t *testing.T) {
skipWindowsProcessTest(t)
envFile := filepath.Join(t.TempDir(), "env.txt")
bin := filepath.Join(t.TempDir(), "tirith")
writeExecutableFile(t, bin, fakeTirithScript(`
{
printf 'policy_root=%s\n' "$TIRITH_POLICY_ROOT"
printf 'server=%s\n' "${TIRITH_SERVER_URL-unset}"
printf 'api=%s\n' "${TIRITH_API_KEY-unset}"
printf 'bypass=%s\n' "${TIRITH-unset}"
printf 'allow_http=%s\n' "${TIRITH_ALLOW_HTTP-unset}"
printf 'gsb=%s\n' "${GOOGLE_SAFE_BROWSING_API_KEY-unset}"
printf 'abusech=%s\n' "${ABUSECH_AUTH_KEY-unset}"
printf 'path=%s\n' "${PATH-unset}"
printf 'home=%s\n' "${HOME-unset}"
printf '%s\n' '---policy---'
cat "$TIRITH_POLICY_ROOT/.tirith/policy.yaml"
} > "$TIRITH_ENV_FILE"
printf '{"findings":[]}'
exit 0
`))
t.Setenv("TIRITH_ENV_FILE", envFile)
t.Setenv("TIRITH_POLICY_ROOT", "/should/not/be/used")
t.Setenv("TIRITH_SERVER_URL", "https://policy.example.invalid")
t.Setenv("TIRITH_API_KEY", "secret")
t.Setenv("TIRITH", "0")
t.Setenv("TIRITH_ALLOW_HTTP", "1")
t.Setenv("GOOGLE_SAFE_BROWSING_API_KEY", "gsb-secret")
t.Setenv("ABUSECH_AUTH_KEY", "abusech-secret")
got := tirithGuard(
context.Background(),
"echo hello",
"",
TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: false},
)
require.Empty(t, got)
data, err := os.ReadFile(envFile)
require.NoError(t, err)
text := string(data)
require.Contains(t, text, "policy_root=")
require.NotContains(t, text, "/should/not/be/used")
require.Contains(t, text, "server=unset")
require.Contains(t, text, "api=unset")
require.Contains(t, text, "bypass=unset")
require.Contains(t, text, "allow_http=unset")
require.Contains(t, text, "gsb=unset")
require.Contains(t, text, "abusech=unset")
require.Contains(t, text, "path=")
require.Contains(t, text, "home=")
require.Contains(t, text, "allow_bypass_env: false")
require.Contains(t, text, "allow_bypass_env_noninteractive: false")
require.Contains(t, text, "auto_update_hours: 0")
require.Contains(t, text, "osv_enabled: false")
require.Contains(t, text, "deps_dev_enabled: false")
require.Contains(t, text, "phishing_army_enabled: false")
}
func TestTirithGuard_UsesCommandWorkingDirectory(t *testing.T) {
skipWindowsProcessTest(t)
cwd := t.TempDir()
pwdFile := filepath.Join(t.TempDir(), "pwd.txt")
bin := filepath.Join(t.TempDir(), "tirith")
writeExecutableFile(t, bin, fakeTirithScript(`
pwd > "$TIRITH_PWD_FILE"
printf '{"findings":[]}'
exit 0
`))
t.Setenv("TIRITH_PWD_FILE", pwdFile)
cfg := TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: false}
got := tirithGuard(context.Background(), "echo hello", cwd, cfg)
require.Empty(t, got)
data, err := os.ReadFile(pwdFile)
require.NoError(t, err)
expectedCWD, err := filepath.EvalSymlinks(cwd)
require.NoError(t, err)
require.Equal(t, expectedCWD, strings.TrimSpace(string(data)))
}
func TestTirithGuard_ExitCodes(t *testing.T) {
skipWindowsProcessTest(t)
tests := []struct {
name string
exitCode int
failOpen bool
wantBlock bool
wantText string
}{
{name: "allow", exitCode: 0, failOpen: false},
{name: "block", exitCode: 1, failOpen: false, wantBlock: true, wantText: "Pipe to shell"},
{name: "warn", exitCode: 2, failOpen: false},
{name: "warn ack defensive", exitCode: 3, failOpen: false},
{name: "unknown fail open", exitCode: 9, failOpen: true},
{name: "unknown fail closed", exitCode: 9, failOpen: false, wantBlock: true, wantText: "fail-closed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bin := filepath.Join(t.TempDir(), "tirith")
script := `printf '{"findings":[{"severity":"HIGH","title":"Pipe to shell"}]}'` +
"\nexit " + itoa(tt.exitCode) + "\n"
writeExecutableFile(t, bin, fakeTirithScript(script))
cfg := TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: tt.failOpen}
got := tirithGuard(context.Background(), "echo hello", "", cfg)
if tt.wantBlock {
require.NotEmpty(t, got)
require.Contains(t, got, tt.wantText)
} else {
require.Empty(t, got)
}
})
}
}
func TestTirithGuard_InvalidJSONKeepsExitVerdict(t *testing.T) {
skipWindowsProcessTest(t)
bin := filepath.Join(t.TempDir(), "tirith")
writeExecutableFile(t, bin, fakeTirithScript("printf 'not json'\nexit 1\n"))
cfg := TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: false}
got := tirithGuard(context.Background(), "echo hello", "", cfg)
require.Contains(t, got, "Command blocked by Tirith")
require.Contains(t, got, "details unavailable")
}
func TestTirithGuard_TimeoutUsesFailOpen(t *testing.T) {
skipWindowsProcessTest(t)
bin := filepath.Join(t.TempDir(), "tirith")
writeExecutableFile(t, bin, fakeTirithScript("sleep 2\nexit 0\n"))
cfg := TirithConfig{Enabled: true, BinPath: bin, Timeout: 1, FailOpen: true}
require.Empty(t, tirithGuard(context.Background(), "echo hello", "", cfg))
cfg.FailOpen = false
got := tirithGuard(context.Background(), "echo hello", "", cfg)
require.Contains(t, got, "fail-closed")
require.Contains(t, got, "timed out")
}
func TestRunTirithCommand_CancelKillsProcessTree(t *testing.T) {
skipWindowsProcessTest(t)
childPIDFile := filepath.Join(t.TempDir(), "child.pid")
bin := filepath.Join(t.TempDir(), "tirith")
writeExecutableFile(t, bin, fakeTirithScript(`
sleep 30 &
echo $! > "$TIRITH_CHILD_PID_FILE"
wait
`))
t.Setenv("TIRITH_CHILD_PID_FILE", childPIDFile)
start := time.Now()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cmd := exec.Command(bin)
cmd.Env = append(os.Environ(), "TIRITH_CHILD_PID_FILE="+childPIDFile)
errCh := make(chan error, 1)
go func() {
errCh <- runTirithCommand(ctx, cmd)
}()
require.Eventually(t, func() bool {
_, err := os.Stat(childPIDFile)
return err == nil
}, 5*time.Second, 100*time.Millisecond)
cancel()
var runErr error
require.Eventually(t, func() bool {
select {
case runErr = <-errCh:
return true
default:
return false
}
}, 5*time.Second, 100*time.Millisecond)
require.Error(t, runErr)
require.Less(t, time.Since(start), 7*time.Second)
data, err := os.ReadFile(childPIDFile)
require.NoError(t, err)
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
require.NoError(t, err)
require.Eventually(t, func() bool {
return !tirithTestProcessExists(pid)
}, 5*time.Second, 100*time.Millisecond)
}
func TestTirithGuard_ContextCanceledBlocksExecution(t *testing.T) {
skipWindowsProcessTest(t)
bin := filepath.Join(t.TempDir(), "tirith")
writeExecutableFile(t, bin, fakeTirithScript("sleep 2\nexit 0\n"))
ctx, cancel := context.WithCancel(context.Background())
cancel()
got := tirithGuard(ctx, "echo hello", "", TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: true})
require.Equal(t, "Tirith security scan canceled", got)
}
func TestTirithGuard_ParentDeadlineBlocksExecution(t *testing.T) {
skipWindowsProcessTest(t)
bin := filepath.Join(t.TempDir(), "tirith")
writeExecutableFile(t, bin, fakeTirithScript("sleep 2\nexit 0\n"))
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
got := tirithGuard(ctx, "echo hello", "", TirithConfig{Enabled: true, BinPath: bin, Timeout: 5, FailOpen: true})
require.Equal(t, "Tirith security scan canceled", got)
}
func TestCappedBuffer_DiscardReturnsFullWriteLength(t *testing.T) {
var buf cappedBuffer
buf.limit = 5
n, err := buf.Write([]byte("abcdef"))
require.NoError(t, err)
require.Equal(t, 6, n)
require.Equal(t, "abcde", buf.String())
n, err = buf.Write([]byte("ghij"))
require.NoError(t, err)
require.Equal(t, 4, n)
require.Equal(t, "abcde", buf.String())
}
func TestTirithSummarize_SanitizesAndCapsFindings(t *testing.T) {
findings := make([]map[string]string, 0, tirithMaxFindings+5)
findings = append(findings, map[string]string{
"severity": "\x1b[31mHIGH\x1b[0m",
"title": "bad\u202e\u200b title",
})
for i := 0; i < tirithMaxFindings+4; i++ {
findings = append(findings, map[string]string{"severity": "LOW", "title": "extra"})
}
payload, err := json.Marshal(map[string]any{"findings": findings})
require.NoError(t, err)
got := tirithSummarize(payload)
require.Contains(t, got, "[HIGH] bad title")
require.NotContains(t, got, "\x1b")
require.NotContains(t, got, "[31m")
require.NotContains(t, got, "\u202e")
require.NotContains(t, got, "\u200b")
require.Contains(t, got, "...and 5 more")
require.LessOrEqual(t, len([]rune(got)), tirithMaxSummaryRunes+3)
}
func TestTirithSummarize_CapsSummary(t *testing.T) {
payload, err := json.Marshal(map[string]any{"summary": strings.Repeat("x", tirithMaxSummaryRunes+20)})
require.NoError(t, err)
got := tirithSummarize(payload)
require.Len(t, []rune(got), tirithMaxSummaryRunes+3)
require.True(t, strings.HasSuffix(got, "..."))
}
func TestTirithWarningCacheSuppressesRepeatedScannerFailureLogs(t *testing.T) {
resetTirithWarningCacheForTest()
var logs bytes.Buffer
previousOutput := log.Writer()
previousFlags := log.Flags()
log.SetOutput(&logs)
log.SetFlags(0)
defer func() {
log.SetOutput(previousOutput)
log.SetFlags(previousFlags)
}()
missing := filepath.Join(t.TempDir(), "missing-tirith")
cfg := TirithConfig{Enabled: true, BinPath: missing, Timeout: 5, FailOpen: true}
require.Empty(t, tirithGuard(context.Background(), "echo one", "", cfg))
require.Empty(t, tirithGuard(context.Background(), "echo two", "", cfg))
require.Equal(t, 1, strings.Count(logs.String(), "unavailable (fail-open)"))
}
func TestTirithSummarize_InvalidAndEmptyJSON(t *testing.T) {
require.Contains(t, tirithSummarize([]byte("not json")), "details unavailable")
require.Equal(t, "security issue detected", tirithSummarize([]byte(`{"findings":[]}`)))
require.Equal(t, "security issue detected", tirithSummarize(nil))
}
func resetTirithWarningCacheForTest() {
tirithWarningCache.Lock()
defer tirithWarningCache.Unlock()
tirithWarningCache.seen = make(map[string]struct{})
}
func writeExecutableFile(t *testing.T, path, content string) {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte(content), 0o755))
if runtime.GOOS != "windows" {
require.NoError(t, os.Chmod(path, 0o755))
}
}
func fakeTirithScript(body string) string {
return "#!/bin/sh\n" + body
}
func skipWindowsProcessTest(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("shell-script Tirith fake is Unix-only")
}
}
func tirithTestProcessExists(pid int) bool {
return exec.Command("kill", "-0", itoa(pid)).Run() == nil
}
func itoa(v int) string {
if v == 0 {
return "0"
}
var digits [20]byte
i := len(digits)
for v > 0 {
i--
digits[i] = byte('0' + v%10)
v /= 10
}
return string(digits[i:])
}