This commit is contained in:
Sheeki 2026-05-15 08:53:17 +00:00 committed by GitHub
commit 707b0f8102
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1321 additions and 7 deletions

View file

@ -391,7 +391,13 @@
"enabled": true,
"enable_deny_patterns": true,
"custom_deny_patterns": null,
"custom_allow_patterns": null
"custom_allow_patterns": null,
"tirith": {
"enabled": false,
"bin": "tirith",
"timeout_seconds": 5,
"fail_open": true
}
},
"skills": {
"enabled": true,

View file

@ -191,6 +191,7 @@ The exec tool is used to execute shell commands.
| `enabled` | bool | true | Enable the exec tool |
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
| `tirith.enabled` | bool | false | Enable optional Tirith pre-exec scanning |
### Disabling the Exec Tool
@ -219,6 +220,96 @@ PICOCLAW_TOOLS_EXEC_ENABLED=false
- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns
- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked
### Optional Tirith Security Scan
PicoClaw can run [Tirith](https://github.com/sheeki03/tirith) before executing a command. This is disabled by default
and has no path lookup or subprocess overhead unless explicitly enabled.
Tirith analyzes command text for content-level threats such as homograph and punycode URLs, pipe-to-interpreter
chains (`curl | bash`, `wget | sh`, PowerShell `iwr | iex`, and wrapper variants with `sudo` / `env`), base64
decode-execute chains, terminal-control injection, suspicious package or URL installs, insecure transport, shortened
URLs, and credential or file exfiltration patterns. If the local Tirith installation has its signed threat-intelligence
database installed, `tirith check` can also apply package, hostname, and IP reputation matches.
PicoClaw does not download, install, vendor, or bundle Tirith. Install Tirith separately, then enable it in config:
```json
{
"tools": {
"exec": {
"tirith": {
"enabled": true,
"bin": "tirith",
"timeout_seconds": 5,
"fail_open": true
}
}
}
}
```
| Config | Type | Default | Description |
|--------|------|---------|-------------|
| `tirith.enabled` | bool | `false` | Run Tirith before command execution |
| `tirith.bin` | string | `"tirith"` | Tirith binary name from `PATH`, or an explicit absolute/relative path. Empty values are treated as `"tirith"` |
| `tirith.timeout_seconds` | int | `5` | Scanner timeout in seconds |
| `tirith.fail_open` | bool | `true` | Allow commands if Tirith is missing or the scanner fails operationally |
Explicit relative `tirith.bin` paths are resolved from PicoClaw's process working directory. Directories are rejected
on all platforms, and non-executable regular files are rejected on Unix.
Environment variables:
```bash
PICOCLAW_TOOLS_EXEC_TIRITH_ENABLED=true
PICOCLAW_TOOLS_EXEC_TIRITH_BIN=tirith
PICOCLAW_TOOLS_EXEC_TIRITH_TIMEOUT_SECONDS=5
PICOCLAW_TOOLS_EXEC_TIRITH_FAIL_OPEN=true
```
For this integration, PicoClaw starts `tirith check` with a temporary local-only policy. The pre-exec check disables
Tirith threat-DB auto-update, remote policy credentials inherited from the environment, live OSV/deps.dev enrichment,
Google Safe Browsing enrichment, and supplemental phishing/abuse.ch feed downloads. Local threat-database files that
are already installed remain available to Tirith, but PicoClaw does not install, update, or download them during
command execution.
Install methods depend on the Tirith project release you choose. Common options include:
```bash
brew install sheeki03/tap/tirith
cargo install tirith
tirith threat-db update # optional, enables local threat-intelligence database matches
```
You can also download a release binary from the Tirith project. PicoClaw only needs the final `tirith` executable
to be on `PATH` or configured with `tools.exec.tirith.bin`.
Supported prebuilt platform coverage is determined by the Tirith binary you install. Common Tirith release targets
include macOS x86_64/aarch64, Linux x86_64/aarch64, and Windows x86_64. PicoClaw passes `--shell posix` on non-Windows
systems and `--shell powershell` on Windows to match its command execution path.
PicoClaw runs:
```text
tirith check --format json --non-interactive --no-daemon --shell <mode> -- <command>
```
Tirith's exit code is the verdict source of truth:
| Exit code | PicoClaw behavior |
|-----------|-------------------|
| `0` | Allow |
| `1` | Block |
| `2` | Log a warning and allow |
| Other scanner failure | Allow when `fail_open=true`; block when `fail_open=false` |
Direct non-interactive `tirith check` normally returns `0`, `1`, or `2`. PicoClaw also handles Tirith's warn-ack exit
defensively by logging and allowing, but it is not expected in normal non-interactive use.
Tirith scans the top-level `exec` command before PicoClaw starts the process. It does not inspect later `write` or
`send-keys` input sent into an already-running background or PTY session, and it does not recursively inspect child
processes spawned by the command after it starts.
### Default Blocked Command Patterns
By default, PicoClaw blocks the following dangerous commands:

View file

@ -928,13 +928,25 @@ type CronToolsConfig struct {
AllowCommand bool ` json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"`
}
type TirithConfig struct {
Enabled bool `env:"ENABLED" json:"enabled"`
Bin string `env:"BIN" json:"bin"`
TimeoutSeconds int `env:"TIMEOUT_SECONDS" json:"timeout_seconds"`
FailOpen bool `env:"FAIL_OPEN" json:"fail_open"`
}
type ExecConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
EnableDenyPatterns bool ` json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
AllowRemote bool ` json:"allow_remote" env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE"`
CustomDenyPatterns []string ` json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
CustomAllowPatterns []string ` json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"` // 0 means use default (60s)
ToolConfig `envPrefix:"PICOCLAW_TOOLS_EXEC_"`
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
AllowRemote bool `json:"allow_remote" env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE"`
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
CustomAllowPatterns []string `json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
// 0 means use default (60s)
TimeoutSeconds int `json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"`
Tirith TirithConfig `json:"tirith" envPrefix:"PICOCLAW_TOOLS_EXEC_TIRITH_"`
}
type SkillsToolsConfig struct {

View file

@ -1233,6 +1233,118 @@ func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
}
}
func TestDefaultConfig_TirithDisabled(t *testing.T) {
cfg := DefaultConfig()
if cfg.Tools.Exec.Tirith.Enabled {
t.Fatal("DefaultConfig().Tools.Exec.Tirith.Enabled should be false")
}
if cfg.Tools.Exec.Tirith.Bin != "tirith" {
t.Fatalf("DefaultConfig().Tools.Exec.Tirith.Bin = %q, want tirith", cfg.Tools.Exec.Tirith.Bin)
}
if cfg.Tools.Exec.Tirith.TimeoutSeconds != 5 {
t.Fatalf("DefaultConfig().Tools.Exec.Tirith.TimeoutSeconds = %d, want 5", cfg.Tools.Exec.Tirith.TimeoutSeconds)
}
if !cfg.Tools.Exec.Tirith.FailOpen {
t.Fatal("DefaultConfig().Tools.Exec.Tirith.FailOpen should be true")
}
}
func TestLoadConfig_TirithNestedKeysAndEnv(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
raw := `{
"version": 3,
"tools": {
"exec": {
"tirith": {
"enabled": true,
"bin": "/usr/local/bin/tirith",
"timeout_seconds": 7,
"fail_open": false
}
}
}
}`
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if !cfg.Tools.Exec.Tirith.Enabled {
t.Fatal("Tirith.Enabled = false, want true")
}
if cfg.Tools.Exec.Tirith.Bin != "/usr/local/bin/tirith" {
t.Fatalf("Tirith.Bin = %q", cfg.Tools.Exec.Tirith.Bin)
}
if cfg.Tools.Exec.Tirith.TimeoutSeconds != 7 {
t.Fatalf("Tirith.TimeoutSeconds = %d, want 7", cfg.Tools.Exec.Tirith.TimeoutSeconds)
}
if cfg.Tools.Exec.Tirith.FailOpen {
t.Fatal("Tirith.FailOpen = true, want false")
}
t.Setenv("PICOCLAW_TOOLS_EXEC_TIRITH_ENABLED", "false")
t.Setenv("PICOCLAW_TOOLS_EXEC_TIRITH_BIN", "/opt/tirith")
t.Setenv("PICOCLAW_TOOLS_EXEC_TIRITH_TIMEOUT_SECONDS", "11")
t.Setenv("PICOCLAW_TOOLS_EXEC_TIRITH_FAIL_OPEN", "true")
cfg, err = LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() with env error: %v", err)
}
if cfg.Tools.Exec.Tirith.Enabled {
t.Fatal("env Tirith.Enabled = true, want false")
}
if cfg.Tools.Exec.Tirith.Bin != "/opt/tirith" {
t.Fatalf("env Tirith.Bin = %q, want /opt/tirith", cfg.Tools.Exec.Tirith.Bin)
}
if cfg.Tools.Exec.Tirith.TimeoutSeconds != 11 {
t.Fatalf("env Tirith.TimeoutSeconds = %d, want 11", cfg.Tools.Exec.Tirith.TimeoutSeconds)
}
if !cfg.Tools.Exec.Tirith.FailOpen {
t.Fatal("env Tirith.FailOpen = false, want true")
}
}
func TestLoadConfig_TirithOldPRKeysAreUnknown(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
raw := `{
"version": 3,
"tools": {
"exec": {
"tirith": {
"tirith_enabled": true,
"tirith_bin": "tirith",
"tirith_timeout": 5,
"tirith_fail_open": true
}
}
}
}`
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
_, err := LoadConfig(configPath)
if err == nil {
t.Fatal("expected old Tirith PR keys to be rejected as unknown")
}
for _, field := range []string{
"tools.exec.tirith.tirith_enabled",
"tools.exec.tirith.tirith_bin",
"tools.exec.tirith.tirith_timeout",
"tools.exec.tirith.tirith_fail_open",
} {
if !strings.Contains(err.Error(), field) {
t.Fatalf("expected unknown field %s in error, got %q", field, err.Error())
}
}
}
func TestDefaultConfig_FilterSensitiveDataEnabled(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Tools.FilterSensitiveData {

View file

@ -381,6 +381,12 @@ func DefaultConfig() *Config {
EnableDenyPatterns: true,
AllowRemote: true,
TimeoutSeconds: 60,
Tirith: TirithConfig{
Enabled: false,
Bin: "tirith",
TimeoutSeconds: 5,
FailOpen: true,
},
},
Skills: SkillsToolsConfig{
ToolConfig: ToolConfig{

View file

@ -44,6 +44,7 @@ type ExecTool struct {
restrictToWorkspace bool
allowRemote bool
sessionManager *SessionManager
tirithConfig TirithConfig
}
var (
@ -168,6 +169,22 @@ func NewExecToolWithConfig(
timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second
}
tirithCfg := TirithConfig{
Enabled: false,
BinPath: "tirith",
Timeout: 5,
FailOpen: true,
}
if cfg != nil {
tc := cfg.Tools.Exec.Tirith
tirithCfg = TirithConfig{
Enabled: tc.Enabled,
BinPath: tc.Bin,
Timeout: tc.TimeoutSeconds,
FailOpen: tc.FailOpen,
}
}
return &ExecTool{
workingDir: workingDir,
timeout: timeout,
@ -178,6 +195,7 @@ func NewExecToolWithConfig(
restrictToWorkspace: restrict,
allowRemote: allowRemote,
sessionManager: getSessionManager(),
tirithConfig: tirithCfg,
}, nil
}
@ -322,6 +340,11 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes
return ErrorResult(guardError)
}
// Tirith content-level security gate (after cheap regex guards)
if tirithError := tirithGuard(ctx, command, cwd, t.tirithConfig); tirithError != "" {
return ErrorResult(tirithError)
}
// Re-resolve symlinks immediately before execution to shrink the TOCTOU window
// between validation and cmd.Dir assignment.
if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir {

543
pkg/tools/tirith.go Normal file
View file

@ -0,0 +1,543 @@
// Package tools integrates Tirith pre-exec security scanning.
//
// Tirith (https://github.com/sheeki03/tirith) is a terminal security tool
// that scans commands for content-level threats: homograph/punycode URLs,
// pipe-to-interpreter patterns, base64 decode-execute chains, terminal
// injection, suspicious packages/URLs, insecure transport, and local
// threat-intelligence matches when configured in Tirith.
//
// PicoClaw invokes a locally installed Tirith binary when explicitly enabled.
// It does not download, install, vendor, or bundle Tirith, and it disables
// Tirith auto-update / live enrichment for this pre-exec check.
package tools
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
)
const (
tirithDefaultBin = "tirith"
tirithDefaultTimeout = 5
tirithStdoutLimitBytes = 256 * 1024
tirithStderrLimitBytes = 8 * 1024
tirithMaxFindings = 50
tirithMaxSummaryRunes = 500
)
var tirithScrubbedEnvVars = map[string]struct{}{
"TIRITH": {},
"TIRITH_POLICY_ROOT": {},
"TIRITH_SERVER_URL": {},
"TIRITH_API_KEY": {},
"TIRITH_ALLOW_HTTP": {},
"GOOGLE_SAFE_BROWSING_API_KEY": {},
"GOOGLE_SAFE_BROWSING_KEY": {},
"SAFE_BROWSING_API_KEY": {},
"ABUSECH_AUTH_KEY": {},
"ABUSE_CH_AUTH_KEY": {},
"URLHAUS_AUTH_KEY": {},
"THREATFOX_AUTH_KEY": {},
}
const tirithLocalOnlyPolicy = `policy_server_url: null
policy_server_api_key: null
allow_bypass_env: false
allow_bypass_env_noninteractive: false
threat_intel:
auto_update_hours: 0
osv_enabled: false
deps_dev_enabled: false
google_safe_browsing_key: null
abusech_auth_key: null
phishing_army_enabled: false
`
// TirithConfig holds Tirith security scanner settings (tools-internal).
// Mapped from config.TirithConfig at ExecTool construction time.
type TirithConfig struct {
Enabled bool
BinPath string
Timeout int
FailOpen bool
}
var tirithWarningCache = struct {
sync.Mutex
seen map[string]struct{}
}{
seen: make(map[string]struct{}),
}
// tirithGuard scans a command with tirith for content-level threats.
// Call AFTER guardCommand() because cheap regex guards reject known-bad commands first.
// Returns empty string if allowed, error message if blocked.
func tirithGuard(ctx context.Context, command, cwd string, cfg TirithConfig) string {
if !cfg.Enabled {
return ""
}
binPath, cacheKey, err := resolveTirithPath(cfg.BinPath)
if err != nil {
return tirithScannerFailure(cacheKey, cfg, err)
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = tirithDefaultTimeout
}
policyRoot, cleanupPolicy, err := createTirithLocalOnlyPolicyRoot()
if err != nil {
return tirithScannerFailure(cacheKey, cfg, err)
}
defer cleanupPolicy()
parentCtx := ctx
ctx, cancel := context.WithTimeout(parentCtx, time.Duration(timeout)*time.Second)
defer cancel()
shellFlag := "posix"
if runtime.GOOS == "windows" {
shellFlag = "powershell"
}
cmd := exec.Command(binPath, "check", "--format", "json", "--non-interactive",
"--no-daemon", "--shell", shellFlag, "--", command)
if cwd != "" {
cmd.Dir = cwd
}
cmd.Env = tirithSubprocessEnv(policyRoot)
var stdout cappedBuffer
var stderr cappedBuffer
stdout.limit = tirithStdoutLimitBytes
stderr.limit = tirithStderrLimitBytes
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = runTirithCommand(ctx, cmd)
if err != nil {
if parentCtx.Err() != nil {
return "Tirith security scan canceled"
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return tirithScannerFailure(cacheKey, cfg, fmt.Errorf("timed out after %ds", timeout))
}
if errors.Is(ctx.Err(), context.Canceled) {
return "Tirith security scan canceled"
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return tirithHandleExit(exitErr.ExitCode(), stdout.Bytes(), cfg)
}
if stderr.Len() > 0 {
err = fmt.Errorf("%w: %s", err, tirithSanitizeText(stderr.String()))
}
return tirithScannerFailure(cacheKey, cfg, err)
}
return tirithHandleExit(0, stdout.Bytes(), cfg)
}
func runTirithCommand(ctx context.Context, cmd *exec.Cmd) error {
if err := ctx.Err(); err != nil {
return err
}
prepareCommandForTermination(cmd)
if err := cmd.Start(); err != nil {
return err
}
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case err := <-done:
return err
case <-ctx.Done():
_ = terminateProcessTree(cmd)
err := <-done
if err != nil {
return err
}
return ctx.Err()
}
}
func createTirithLocalOnlyPolicyRoot() (string, func(), error) {
root, err := os.MkdirTemp("", "picoclaw-tirith-policy-*")
if err != nil {
return "", func() {}, fmt.Errorf("create local-only Tirith policy root: %w", err)
}
cleanup := func() {
_ = os.RemoveAll(root)
}
policyDir := filepath.Join(root, ".tirith")
if err := os.MkdirAll(policyDir, 0o700); err != nil {
cleanup()
return "", func() {}, fmt.Errorf("create local-only Tirith policy directory: %w", err)
}
policyPath := filepath.Join(policyDir, "policy.yaml")
if err := os.WriteFile(policyPath, []byte(tirithLocalOnlyPolicy), 0o600); err != nil {
cleanup()
return "", func() {}, fmt.Errorf("write local-only Tirith policy: %w", err)
}
return root, cleanup, nil
}
func tirithSubprocessEnv(policyRoot string) []string {
env := make([]string, 0, len(os.Environ())+1)
for _, entry := range os.Environ() {
name, _, ok := strings.Cut(entry, "=")
if !ok {
continue
}
if tirithShouldScrubEnv(name) {
continue
}
env = append(env, entry)
}
env = append(env, "TIRITH_POLICY_ROOT="+policyRoot)
return env
}
func tirithShouldScrubEnv(name string) bool {
_, ok := tirithScrubbedEnvVars[strings.ToUpper(name)]
return ok
}
func tirithHandleExit(exitCode int, jsonOutput []byte, cfg TirithConfig) string {
switch exitCode {
case 0:
return ""
case 1:
return fmt.Sprintf("Command blocked by Tirith security scan: %s", tirithSummarize(jsonOutput))
case 2:
log.Printf("[tirith] warning: %s", tirithSummarize(jsonOutput))
return ""
case 3:
log.Printf(
"[tirith] warning acknowledgement required; allowing in non-interactive mode: %s",
tirithSummarize(jsonOutput),
)
return ""
default:
return tirithScannerFailure(
normalizeTirithBin(cfg.BinPath),
cfg,
fmt.Errorf("unexpected exit code %d", exitCode),
)
}
}
func tirithScannerFailure(cacheKey string, cfg TirithConfig, err error) string {
message := tirithSanitizeText(err.Error())
if cfg.FailOpen {
tirithLogOncef(cacheKey, "scanner-failure", "[tirith] unavailable (fail-open): %s", message)
return ""
}
return fmt.Sprintf("Tirith security scan failed (fail-closed): %s", message)
}
func tirithLogOncef(cacheKey, category, format string, args ...any) {
cacheKey = normalizeTirithBin(cacheKey)
key := cacheKey + "\x00" + category + "\x00" + fmt.Sprintf(format, args...)
tirithWarningCache.Lock()
defer tirithWarningCache.Unlock()
if _, ok := tirithWarningCache.seen[key]; ok {
return
}
tirithWarningCache.seen[key] = struct{}{}
log.Printf(format, args...)
}
func tirithSummarize(jsonOutput []byte) string {
if len(jsonOutput) == 0 {
return "security issue detected"
}
var data struct {
Summary string `json:"summary"`
Findings []struct {
Severity string `json:"severity"`
Title string `json:"title"`
Message string `json:"message"`
Description string `json:"description"`
} `json:"findings"`
}
if err := json.Unmarshal(jsonOutput, &data); err != nil {
return "security issue detected (details unavailable)"
}
if summary := tirithTruncateRunes(tirithSanitizeText(data.Summary), tirithMaxSummaryRunes); summary != "" {
return summary
}
limit := len(data.Findings)
if limit > tirithMaxFindings {
limit = tirithMaxFindings
}
parts := make([]string, 0, limit)
for _, f := range data.Findings[:limit] {
title := f.Title
if title == "" {
title = f.Message
}
if title == "" {
title = f.Description
}
title = tirithSanitizeText(title)
if title == "" {
continue
}
severity := tirithSanitizeText(f.Severity)
if severity == "" {
parts = append(parts, title)
} else {
parts = append(parts, fmt.Sprintf("[%s] %s", severity, title))
}
}
if len(parts) == 0 {
return "security issue detected"
}
if len(data.Findings) > limit {
return tirithJoinFindingSummary(parts, len(data.Findings)-limit)
}
return tirithTruncateRunes(strings.Join(parts, "; "), tirithMaxSummaryRunes)
}
func tirithJoinFindingSummary(parts []string, more int) string {
summary := strings.Join(parts, "; ")
if more <= 0 {
return tirithTruncateRunes(summary, tirithMaxSummaryRunes)
}
suffix := fmt.Sprintf("; ...and %d more", more)
summaryRunes := []rune(summary)
suffixRunes := []rune(suffix)
if len(summaryRunes)+len(suffixRunes) <= tirithMaxSummaryRunes {
return summary + suffix
}
prefixLimit := tirithMaxSummaryRunes - len(suffixRunes)
if prefixLimit <= 0 {
return tirithTruncateRunes(strings.TrimPrefix(suffix, "; "), tirithMaxSummaryRunes)
}
prefix := strings.TrimSpace(string(summaryRunes[:prefixLimit]))
prefix = strings.TrimSuffix(prefix, ";")
if prefix == "" {
return strings.TrimPrefix(suffix, "; ")
}
return prefix + suffix
}
// resolveTirithPath resolves only a user-installed Tirith binary. It deliberately
// does not cache failed lookups so installing Tirith after a miss works immediately.
func resolveTirithPath(configured string) (string, string, error) {
normalized := normalizeTirithBin(configured)
if isExplicitTirithPath(normalized) {
if err := validateTirithPath(normalized); err != nil {
return "", normalized, err
}
return normalized, normalized, nil
}
path, err := exec.LookPath(normalized)
if err != nil {
return "", normalized, err
}
if err := validateTirithPath(path); err != nil {
return "", normalized, err
}
return path, normalized, nil
}
func normalizeTirithBin(configured string) string {
bin := strings.TrimSpace(configured)
if bin == "" {
bin = tirithDefaultBin
}
if bin == "~" || strings.HasPrefix(bin, "~/") || strings.HasPrefix(bin, `~\`) {
if home, err := os.UserHomeDir(); err == nil && home != "" {
if bin == "~" {
bin = home
} else {
bin = filepath.Join(home, bin[2:])
}
}
}
if isExplicitTirithPath(bin) {
cleaned := filepath.Clean(bin)
if filepath.IsAbs(cleaned) {
return cleaned
}
if abs, err := filepath.Abs(cleaned); err == nil {
return abs
}
return cleaned
}
return bin
}
func isExplicitTirithPath(path string) bool {
return filepath.IsAbs(path) ||
strings.Contains(path, "/") ||
strings.Contains(path, `\`)
}
func validateTirithPath(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.IsDir() {
return fmt.Errorf("%s is a directory", path)
}
if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 {
return fmt.Errorf("%s is not executable", path)
}
return nil
}
type cappedBuffer struct {
limit int
buf bytes.Buffer
}
func (b *cappedBuffer) Write(p []byte) (int, error) {
written := len(p)
if b.limit <= 0 || b.buf.Len() >= b.limit {
return written, nil
}
remaining := b.limit - b.buf.Len()
if len(p) > remaining {
p = p[:remaining]
}
_, _ = b.buf.Write(p)
return written, nil
}
func (b *cappedBuffer) Bytes() []byte {
return b.buf.Bytes()
}
func (b *cappedBuffer) String() string {
return b.buf.String()
}
func (b *cappedBuffer) Len() int {
return b.buf.Len()
}
func tirithSanitizeText(input string) string {
input = tirithStripANSI(input)
var b strings.Builder
b.Grow(len(input))
for _, r := range input {
if tirithDropRune(r) {
continue
}
b.WriteRune(r)
}
return strings.Join(strings.Fields(b.String()), " ")
}
func tirithDropRune(r rune) bool {
if unicode.IsControl(r) {
return true
}
switch {
case r >= '\u202a' && r <= '\u202e':
return true
case r >= '\u2066' && r <= '\u2069':
return true
case r >= '\u200b' && r <= '\u200f':
return true
case r == '\u2060' || r == '\ufeff':
return true
case r >= '\ufe00' && r <= '\ufe0f':
return true
case r >= '\U000e0100' && r <= '\U000e01ef':
return true
case r >= '\U000e0000' && r <= '\U000e007f':
return true
default:
return false
}
}
func tirithStripANSI(input string) string {
var b strings.Builder
for i := 0; i < len(input); {
r, size := utf8.DecodeRuneInString(input[i:])
if r != '\x1b' {
b.WriteString(input[i : i+size])
i += size
continue
}
i += size
if i >= len(input) {
continue
}
switch input[i] {
case '[':
i++
for i < len(input) {
c := input[i]
i++
if c >= 0x40 && c <= 0x7e {
break
}
}
case ']':
i++
for i < len(input) {
if input[i] == 0x07 {
i++
break
}
if input[i] == '\x1b' && i+1 < len(input) && input[i+1] == '\\' {
i += 2
break
}
i++
}
default:
i++
}
}
return b.String()
}
func tirithTruncateRunes(s string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= limit {
return s
}
return string(runes[:limit]) + "..."
}

521
pkg/tools/tirith_test.go Normal file
View file

@ -0,0 +1,521 @@
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:])
}