diff --git a/config/config.example.json b/config/config.example.json index d87f711d0..d9e417724 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -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, diff --git a/docs/reference/tools_configuration.md b/docs/reference/tools_configuration.md index d92f58476..50294d34a 100644 --- a/docs/reference/tools_configuration.md +++ b/docs/reference/tools_configuration.md @@ -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 -- +``` + +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: diff --git a/pkg/config/config.go b/pkg/config/config.go index 53eaef059..3496a234b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d744e15dc..5af80bbb6 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -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 { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 6171f4626..657bbd69d 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -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{ diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index a570ac9ec..ea50ca776 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -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 { diff --git a/pkg/tools/tirith.go b/pkg/tools/tirith.go new file mode 100644 index 000000000..ff5d0980e --- /dev/null +++ b/pkg/tools/tirith.go @@ -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]) + "..." +} diff --git a/pkg/tools/tirith_test.go b/pkg/tools/tirith_test.go new file mode 100644 index 000000000..5e4cfb1a8 --- /dev/null +++ b/pkg/tools/tirith_test.go @@ -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:]) +}