Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions cmd/pitot/doctor_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,17 @@ func doctorHost(host adapters.Host, stdout, stderr io.Writer) error {
fmt.Fprintf(stdout, " config file: ABSENT (%v)\n", readErr)
problems = append(problems, "config file absent")
default:
text := string(data)
hasEvent := strings.Contains(text, `event = "`+probe.hookEvent+`"`)
hasHookCmd := strings.Contains(text, "pitot "+hookCmd) || strings.Contains(text, hookCmd)
if hasEvent && hasHookCmd {
fmt.Fprintf(stdout, " %s hook: FOUND invoking `pitot %s`\n", probe.hookEvent, hookCmd)
} else {
status := currentKimiHookStatus(data)
switch status {
case kimiHookConfigured:
fmt.Fprintf(stdout, " %s hook: FOUND declaring `pitot %s` for %s\n", probe.hookEvent, hookCmd, probe.matcher)
case kimiHookInvalid:
fmt.Fprintln(stdout, " PreToolUse hook: INVALID — check TOML syntax and hook field names, types, events and timeout")
problems = append(problems, "hook configuration invalid")
case kimiHookUnverified:
fmt.Fprintln(stdout, " PreToolUse hook: UNVERIFIED — manually verify the matcher and command; wrappers, expansions and unsupported syntax cannot establish readiness")
problems = append(problems, "hook routing unverified")
default:
fmt.Fprintf(stdout, " %s hook: MISSING — add a [[hooks]] entry (event = %q, matcher = %q, command = \"pitot %s\")\n",
probe.hookEvent, probe.hookEvent, probe.matcher, hookCmd)
problems = append(problems, "hook not configured")
Expand Down Expand Up @@ -193,7 +198,7 @@ func doctorHost(host adapters.Host, stdout, stderr io.Writer) error {
if len(problems) > 0 {
return fmt.Errorf("pitot doctor: %s host check found %d issue(s): %s", host, len(problems), strings.Join(problems, "; "))
}
fmt.Fprintf(stdout, " ready: %s can route its shell boundary to Pitot\n", host)
fmt.Fprintf(stdout, " ready: %s declares a shell hook to Pitot; hook execution and controller connection are not checked\n", host)
return nil
}

Expand Down
204 changes: 204 additions & 0 deletions cmd/pitot/doctor_kimi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package main

import (
"path"
"runtime"
"strings"

"github.com/pelletier/go-toml/v2"
)

type kimiHookStatus string

const (
kimiHookConfigured kimiHookStatus = "FOUND"
kimiHookMissing kimiHookStatus = "MISSING"
kimiHookInvalid kimiHookStatus = "INVALID"
kimiHookUnverified kimiHookStatus = "UNVERIFIED"
)

// inspectKimiHooks checks declarations only. It never executes a hook command,
// and recognizes a bounded command/matcher subset rather than guessing how the
// host interprets arbitrary shell scripts or regular expressions.
func inspectKimiHooks(data []byte, platform string) kimiHookStatus {
var document map[string]any
if err := toml.Unmarshal(data, &document); err != nil {
// Parser errors can contain source text, including provider credentials.
return kimiHookInvalid
}
value, present := document["hooks"]
if !present {
return kimiHookMissing
}
hooks, ok := value.([]any)
if !ok {
return kimiHookInvalid
}
configured, unverified := false, false
for _, value := range hooks {
hook, ok := value.(map[string]any)
if !ok || !validKimiHook(hook) {
return kimiHookInvalid
}
if hook["event"] != "PreToolUse" {
continue
}
matcherStatus := kimiBashMatcher(hook)
if matcherStatus == kimiHookMissing {
continue
}
commandStatus := kimiHookCommand(hook["command"].(string), platform)
if commandStatus == kimiHookMissing {
continue
}
if matcherStatus == kimiHookConfigured && commandStatus == kimiHookConfigured {
configured = true
} else {
unverified = true
}
}
if configured {
return kimiHookConfigured
}
if unverified {
return kimiHookUnverified
}
return kimiHookMissing
}

func validKimiHook(hook map[string]any) bool {
for key := range hook {
switch key {
case "event", "matcher", "command", "timeout":
default:
return false
}
}
event, ok := hook["event"].(string)
if !ok {
return false
}
// These event names are the published Kimi hook configuration contract.
switch event {
case "UserPromptSubmit", "UserPromptQueued", "PreToolUse", "Stop", "TurnStarted",
"PostToolUse", "PostToolUseFailure", "PermissionRequest", "PermissionResult",
"SessionStart", "SessionEnd", "SessionHeartbeat", "SubagentStart", "SubagentStop",
"TaskStarted", "StopFailure", "Interrupt", "PreCompact", "PostCompact", "Notification":
default:
return false
}
if _, ok := hook["command"].(string); !ok {
return false
}
if value, present := hook["matcher"]; present {
if _, ok := value.(string); !ok {
return false
}
}
if value, present := hook["timeout"]; present {
timeout, ok := value.(int64)
if !ok || timeout < 1 || timeout > 600 {
return false
}
}
return true
}

func kimiBashMatcher(hook map[string]any) kimiHookStatus {
value, present := hook["matcher"]
if !present {
return kimiHookConfigured
}
switch value.(string) {
case "Bash", "^Bash$", ".*":
return kimiHookConfigured
case "ReadFile":
return kimiHookMissing
default:
return kimiHookUnverified
}
}

func kimiHookCommand(command, platform string) kimiHookStatus {
words, ok := kimiLiteralWords(command, platform)
if !ok || len(words) == 0 {
return kimiHookUnverified
}
executable := words[0]
if executable != "pitot" && !(platform != "windows" && strings.Contains(executable, "/") && path.Base(executable) == "pitot") {
if executable == "echo" || executable == "printf" {
return kimiHookMissing
}
return kimiHookUnverified
}
if len(words) < 3 || words[1] != "hook" || words[2] != "kimi" {
return kimiHookMissing
}
for i := 3; i < len(words); i += 2 {
if words[i] != "--runtime" || i+1 >= len(words) || words[i+1] == "" {
return kimiHookUnverified
}
}
return kimiHookConfigured
}

// kimiLiteralWords accepts literal POSIX words with spaces/tabs and quotes.
// Expansions, escapes, operators and other shell syntax require manual review.
// Windows accepts the canonical bare command; quoting and executable-path
// interpretation remain unverified until the host's shell contract is known.
func kimiLiteralWords(command, platform string) ([]string, bool) {
var words []string
var word strings.Builder
var quote byte
started := false
for i := 0; i < len(command); i++ {
c := command[i]
if platform == "windows" && !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || strings.ContainsRune("-_/.: \t", rune(c))) {
return nil, false
}
if c == '\\' || c == '\n' || c == '\r' || c == 0 {
return nil, false
}
if quote != 0 {
if c == quote {
quote = 0
} else {
if quote == '"' && (c == '$' || c == '`') {
return nil, false
}
word.WriteByte(c)
}
continue
}
switch c {
case ' ', '\t':
if started {
words = append(words, word.String())
word.Reset()
started = false
}
case '\'', '"':
if platform == "windows" {
return nil, false
}
quote, started = c, true
case '$', '`', '|', '&', ';', '<', '>', '(', ')', '*', '?', '[', ']', '#', '~', '{', '}':
return nil, false
default:
word.WriteByte(c)
started = true
}
}
if quote != 0 {
return nil, false
}
if started {
words = append(words, word.String())
}
return words, true
}

func currentKimiHookStatus(data []byte) kimiHookStatus {
return inspectKimiHooks(data, runtime.GOOS)
}
103 changes: 103 additions & 0 deletions cmd/pitot/doctor_kimi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package main

import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)

func TestKimiHookDeclarations(t *testing.T) {
hook := func(matcher, command string) string {
return fmt.Sprintf("[[hooks]]\nevent = 'PreToolUse'\nmatcher = %q\ncommand = %q\n", matcher, command)
}
for _, tc := range []struct {
name, config string
want kimiHookStatus
}{
{"canonical", kimiHookConfig, kimiHookConfigured},
{"literal TOML quotes", "[[hooks]]\nevent='PreToolUse'\nmatcher='Bash'\ncommand='pitot hook kimi'\n", kimiHookConfigured},
{"inline array", "hooks=[{event='PreToolUse', matcher='Bash', command='pitot hook kimi'}]", kimiHookConfigured},
{"comments", "# event = \"PreToolUse\"\n# command = \"pitot hook kimi\"", kimiHookMissing},
{"unrelated values", "description='event = \"PreToolUse\"'\nnotes='pitot hook kimi'", kimiHookMissing},
{"different entries", "[[hooks]]\nevent='PreToolUse'\ncommand='echo ordinary'\n[[hooks]]\nevent='PostToolUse'\ncommand='pitot hook kimi'", kimiHookMissing},
{"different target", hook("ReadFile", "pitot hook kimi"), kimiHookMissing},
{"echo", hook("Bash", "echo pitot hook kimi"), kimiHookMissing},
{"wrong subcommand", hook("Bash", "pitot echo 'hook kimi'"), kimiHookMissing},
{"wrong host", hook("Bash", "pitot hook claude"), kimiHookMissing},
{"omitted matcher", "[[hooks]]\nevent='PreToolUse'\ncommand='pitot hook kimi'", kimiHookConfigured},
{"wildcard", hook(".*", "pitot hook kimi"), kimiHookConfigured},
{"anchored target", hook("^Bash$", "pitot hook kimi"), kimiHookConfigured},
{"unknown regex", hook("Bash|ReadFile", "pitot hook kimi"), kimiHookUnverified},
{"lookaround", hook("(?<=x)Bash", "pitot hook kimi"), kimiHookUnverified},
{"empty matcher", hook("", "pitot hook kimi"), kimiHookUnverified},
{"quoted words", hook("Bash", "'pitot' 'hook' 'kimi'"), kimiHookConfigured},
{"quoted path", hook("Bash", "'/opt/Pitot installation/pitot' hook kimi"), kimiHookConfigured},
{"runtime path", hook("Bash", "pitot hook kimi --runtime '/tmp/runtime path'"), kimiHookConfigured},
{"whitespace", hook("Bash", " pitot\t hook kimi "), kimiHookConfigured},
{"dynamic command", hook("Bash", "$(which pitot) hook kimi"), kimiHookUnverified},
{"runtime expansion", hook("Bash", "pitot hook kimi --runtime \"$RUNTIME\""), kimiHookUnverified},
{"pipeline", hook("Bash", "pitot hook kimi | cat"), kimiHookUnverified},
{"separate commands", hook("Bash", "pitot hook kimi\necho ordinary"), kimiHookUnverified},
{"wrapper", hook("Bash", "sh -c 'pitot hook kimi'"), kimiHookUnverified},
{"script", hook("Bash", "/opt/hooks/kimi.sh"), kimiHookUnverified},
{"unsupported arguments", hook("Bash", "pitot hook kimi --other-option"), kimiHookUnverified},
{"missing runtime value", hook("Bash", "pitot hook kimi --runtime"), kimiHookUnverified},
{"missing command", "[[hooks]]\nevent='PreToolUse'", kimiHookInvalid},
{"syntax", "[[hooks]]\nevent='PreToolUse'\ncommand='pitot hook kimi", kimiHookInvalid},
{"hooks string", "hooks='pitot hook kimi'", kimiHookInvalid},
{"non-table", "hooks=[1]", kimiHookInvalid},
{"command type", "[[hooks]]\nevent='PreToolUse'\ncommand=1", kimiHookInvalid},
{"matcher type", "[[hooks]]\nevent='PreToolUse'\ncommand='pitot hook kimi'\nmatcher=1", kimiHookInvalid},
{"timeout range", hook("Bash", "pitot hook kimi") + "timeout=601", kimiHookInvalid},
{"timeout type", hook("Bash", "pitot hook kimi") + "timeout=true", kimiHookInvalid},
{"extra field", hook("Bash", "pitot hook kimi") + "extra='value'", kimiHookInvalid},
{"unknown event", "[[hooks]]\nevent='Unknown'\ncommand='pitot hook kimi'", kimiHookInvalid},
{"invalid peer", hook("Bash", "pitot hook kimi") + "[[hooks]]\nevent='PostToolUse'\ncommand=1", kimiHookInvalid},
{"valid peer", hook("Bash", "pitot hook kimi") + "[[hooks]]\nevent='PostToolUse'\ncommand='echo ordinary'", kimiHookConfigured},
} {
t.Run(tc.name, func(t *testing.T) {
if got := inspectKimiHooks([]byte(tc.config), "linux"); got != tc.want {
t.Fatalf("got %s, want %s", got, tc.want)
}
})
}
}

func TestKimiCommandPlatformBoundaries(t *testing.T) {
for _, platform := range []string{"linux", "darwin", "windows"} {
if got := kimiHookCommand("pitot hook kimi", platform); got != kimiHookConfigured {
t.Fatalf("canonical command on %s: %s", platform, got)
}
}
for _, command := range []string{"'pitot' hook kimi", `"C:\Program Files\Pitot\pitot.exe" hook kimi`, "pitot.exe hook kimi",
"pitot hook kimi --runtime %RUNTIME%", "pitot hook kimi --runtime !RUNTIME!",
"pitot hook kimi --runtime C:/foo^bar", "pitot hook kimi --runtime C:/foo%bar",
"pitot hook kimi --runtime C:/foo!bar", "pitot hook kimi --runtime /tmp/runtime+path"} {
if got := kimiHookCommand(command, "windows"); got != kimiHookUnverified {
t.Fatalf("ambiguous Windows command %q: %s", command, got)
}
}
}

func TestDoctorKimiParserDiagnosticDoesNotExposeConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("KIMI_CODE_HOME", home)
const secret = "test-provider-value-do-not-print"
config := "provider_secret='" + secret + "'\n[[hooks]]\nevent='PreToolUse'\ncommand='unterminated"
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(config), 0o600); err != nil {
t.Fatal(err)
}
// Keep the external agent absent: this test concerns Pitot's parser output.
t.Setenv("PATH", t.TempDir())
var stdout, stderr bytes.Buffer
if err := doctorHost("kimi", &stdout, &stderr); err == nil {
t.Fatal("invalid configuration must fail the diagnostic")
}
output := stdout.String() + stderr.String()
if !strings.Contains(output, "hook: INVALID") || strings.Contains(output, secret) || strings.Contains(output, "unterminated") {
t.Fatalf("unexpected parser diagnostic: %s", output)
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.26

require (
github.com/invopop/jsonschema v0.14.0
github.com/pelletier/go-toml/v2 v2.4.3
go.yaml.in/yaml/v4 v4.0.0-rc.2
golang.org/x/sys v0.42.0
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8i
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
Expand Down
Loading
Loading