fix: align Kimi hooks with official contracts
This commit is contained in:
1 parent
f0e4694da2
commit
c60b46b994
17 files changed
+1973
-617
No files matched your search
@@ -24,8 +24,9 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
|
|||||||
- Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default and canonicalize configured existing directories. Preserve additive TOML, project, plugin, and managed sources. Register exact `PreToolUse` and `PostToolUse` match-all command groups, retain the native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, and commit user hooks plus all four runtime artifacts through one rollback-capable transaction. Require `/hooks` approval for both definition hashes.
|
- Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default and canonicalize configured existing directories. Preserve additive TOML, project, plugin, and managed sources. Register exact `PreToolUse` and `PostToolUse` match-all command groups, retain the native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, and commit user hooks plus all four runtime artifacts through one rollback-capable transaction. Require `/hooks` approval for both definition hashes.
|
||||||
- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
|
- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
|
||||||
- Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance.
|
- Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance.
|
||||||
- Select Kimi Code and legacy `kimi-cli` contracts from runtime evidence. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; create no cross-runtime migration marker.
|
- Select Kimi Code from an explicit `KIMI_CODE_HOME` or an existing `~/.kimi-code` home, and select legacy `kimi-cli` from an existing `~/.kimi` home. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; executable lookup is outside the activation contract, and equal stable and legacy config paths collapse to the stable contract.
|
||||||
- Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it.
|
- Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it. Own only exact three-field hooks for `PreToolUse`, `PostToolUse`, and `PostToolUseFailure`; require one complete triple per runtime identity, use encoded PowerShell commands on Windows, and retain the native snake_case payload.
|
||||||
|
- Generate Kimi guard, config, private key, and audit runtimes together with every selected TOML document in one rollback-capable transaction. Preflight physical paths and existing runtime identity before creating state; status validates the complete runtime metadata, canonical private key, exact agent identity, and non-empty physical scripts.
|
||||||
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources.
|
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources.
|
||||||
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
||||||
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Preserve official input byte-for-byte and translate guard exit code `2` into Cline's JSON stdout cancellation control.
|
- Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Preserve official input byte-for-byte and translate guard exit code `2` into Cline's JSON stdout cancellation control.
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/
|
|||||||
|
|
||||||
Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`.
|
Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`.
|
||||||
|
|
||||||
Kimi installation follows detected runtime evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default. Legacy `kimi-cli` uses `~/.kimi/config.toml`; Elydora updates both contracts only when both runtimes are present.
|
Kimi installation follows home-directory evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and legacy `kimi-cli` uses `~/.kimi/config.toml`. Elydora installs exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks, preserves native snake_case payloads, and commits all selected TOML documents with the four runtime artifacts in one rollback-capable transaction. Windows commands use encoded PowerShell so paths containing spaces, apostrophes, percent signs, and environment-like text remain literal.
|
||||||
|
|
||||||
Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged.
|
Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -29,8 +30,7 @@ func encodeCodexPowerShell(source string) string {
|
|||||||
runes := utf16.Encode([]rune(source))
|
runes := utf16.Encode([]rune(source))
|
||||||
raw := make([]byte, len(runes)*2)
|
raw := make([]byte, len(runes)*2)
|
||||||
for index, value := range runes {
|
for index, value := range runes {
|
||||||
raw[index*2] = byte(value)
|
binary.LittleEndian.PutUint16(raw[index*2:], value)
|
||||||
raw[index*2+1] = byte(value >> 8)
|
|
||||||
}
|
}
|
||||||
return base64.StdEncoding.EncodeToString(raw)
|
return base64.StdEncoding.EncodeToString(raw)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type codexRuntimePaths struct {
|
type codexRuntimePaths struct {
|
||||||
@@ -21,51 +16,6 @@ type codexRuntimePaths struct {
|
|||||||
auditPath string
|
auditPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateCodexPrivateKey(value string) error {
|
|
||||||
seed, err := base64.RawURLEncoding.DecodeString(value)
|
|
||||||
if err != nil || len(seed) != 32 || base64.RawURLEncoding.EncodeToString(seed) != value {
|
|
||||||
return fmt.Errorf("private key must be a canonical 32-byte base64url value")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCodexBaseURL(value string) error {
|
|
||||||
if strings.ContainsRune(value, '\\') {
|
|
||||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
|
||||||
}
|
|
||||||
for _, character := range value {
|
|
||||||
if character < 32 || unicode.IsSpace(character) {
|
|
||||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(value)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
|
||||||
}
|
|
||||||
validScheme := strings.EqualFold(parsed.Scheme, "http") ||
|
|
||||||
strings.EqualFold(parsed.Scheme, "https")
|
|
||||||
if !validScheme ||
|
|
||||||
parsed.Host == "" || parsed.Hostname() == "" || parsed.Opaque != "" {
|
|
||||||
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
|
||||||
}
|
|
||||||
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"base URL must exclude credentials, query parameters, and fragments",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
port := parsed.Port()
|
|
||||||
if strings.HasSuffix(parsed.Host, ":") {
|
|
||||||
return fmt.Errorf("base URL must contain a valid port")
|
|
||||||
}
|
|
||||||
if port != "" {
|
|
||||||
value, convertErr := strconv.Atoi(port)
|
|
||||||
if convertErr != nil || value < 1 || value > 65535 {
|
|
||||||
return fmt.Errorf("base URL must contain a valid port")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCodexInstallConfig(config InstallConfig) error {
|
func validateCodexInstallConfig(config InstallConfig) error {
|
||||||
for _, field := range []struct{ name, value string }{
|
for _, field := range []struct{ name, value string }{
|
||||||
{"agent name", config.AgentName},
|
{"agent name", config.AgentName},
|
||||||
@@ -83,10 +33,10 @@ func validateCodexInstallConfig(config InstallConfig) error {
|
|||||||
if config.AgentName != codexAgentKey {
|
if config.AgentName != codexAgentKey {
|
||||||
return fmt.Errorf("codex installation requires agent name %s", codexAgentKey)
|
return fmt.Errorf("codex installation requires agent name %s", codexAgentKey)
|
||||||
}
|
}
|
||||||
if err := validateCodexPrivateKey(config.PrivateKey); err != nil {
|
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return validateCodexBaseURL(config.BaseURL)
|
return validateManagedBaseURL(config.BaseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) {
|
func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) {
|
||||||
|
|||||||
+88
-87
@@ -2,74 +2,87 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type kimiMutation struct {
|
// KimiPlugin manages Kimi Code and legacy kimi-cli global lifecycle hooks.
|
||||||
document kimiDocument
|
type KimiPlugin struct {
|
||||||
raw []byte
|
rename renameFunc
|
||||||
remove bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// KimiPlugin manages Kimi Code and legacy kimi-cli global lifecycle hooks.
|
// ManagesGuardRuntime reports that Kimi commits its provider guard with the
|
||||||
type KimiPlugin struct{}
|
// audit runtime and every detected user hook document.
|
||||||
|
func (p *KimiPlugin) ManagesGuardRuntime() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (p *KimiPlugin) Install(config InstallConfig) error {
|
// PreflightInstall validates every detected hook source and runtime identity.
|
||||||
if config.AgentID == "" {
|
func (p *KimiPlugin) PreflightInstall(config InstallConfig) error {
|
||||||
return fmt.Errorf("agent ID is required")
|
|
||||||
}
|
|
||||||
documents, err := readAllKimiConfigs()
|
documents, err := readAllKimiConfigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if config.GuardScriptPath == "" {
|
_, _, err = preflightKimiInstallation(config, documents)
|
||||||
return fmt.Errorf("guard script path is required")
|
|
||||||
}
|
|
||||||
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !guardExists {
|
|
||||||
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
|
|
||||||
}
|
|
||||||
hookPath, err := hookScriptPath(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if config.HookScript != "" {
|
|
||||||
hookPath = config.HookScript
|
|
||||||
}
|
|
||||||
nodePath, err := resolveNodeRuntime()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
additions := []kimiHook{
|
|
||||||
buildKimiHook("PreToolUse", buildKimiCommand(nodePath, config.GuardScriptPath)),
|
|
||||||
buildKimiHook("PostToolUse", buildKimiCommand(nodePath, hookPath)),
|
|
||||||
}
|
|
||||||
mutations := make([]kimiMutation, 0, len(documents))
|
|
||||||
for _, document := range documents {
|
|
||||||
raw, err := renderKimiHooks(document, keptKimiHookIndices(document.hooks, ""), additions)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := parseKimiDocument(document.contract, raw, true); err != nil {
|
|
||||||
return fmt.Errorf("validate rendered %s: %w", document.contract.label, err)
|
|
||||||
}
|
|
||||||
mutations = append(mutations, kimiMutation{document: document, raw: raw})
|
|
||||||
}
|
|
||||||
|
|
||||||
runtimeConfig := config
|
func (p *KimiPlugin) Install(config InstallConfig) error {
|
||||||
runtimeConfig.AgentName = kimiAgentKey
|
documents, err := readAllKimiConfigs()
|
||||||
if err := GenerateHookScript(hookPath, runtimeConfig); err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("generate hook script: %w", err)
|
|
||||||
}
|
|
||||||
for _, mutation := range mutations {
|
|
||||||
if err := writeKimiConfig(mutation.document, mutation.raw); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
paths, nodePath, err := preflightKimiInstallation(config, documents)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s: global PreToolUse and PostToolUse hooks installed.\n", kimiRuntimeNames(documents))
|
guardCommand, err := buildKimiCommand(nodePath, paths.guardPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auditCommand, err := buildKimiCommand(nodePath, paths.auditPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
additions := make([]kimiHook, 0, 3)
|
||||||
|
for _, item := range []struct{ event, command string }{
|
||||||
|
{"PreToolUse", guardCommand},
|
||||||
|
{"PostToolUse", auditCommand},
|
||||||
|
{"PostToolUseFailure", auditCommand},
|
||||||
|
} {
|
||||||
|
hook, err := buildKimiHook(item.event, item.command)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
additions = append(additions, hook)
|
||||||
|
}
|
||||||
|
rendered := make([]kimiRenderedDocument, 0, len(documents))
|
||||||
|
for _, document := range documents {
|
||||||
|
keep, err := keptKimiHookIndices(document.hooks, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
change, err := renderKimiChange(document, keep, additions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rendered = append(rendered, change)
|
||||||
|
}
|
||||||
|
changes, err := prepareKimiInstallationChanges(config, paths, rendered)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeKimiChanges(
|
||||||
|
changes,
|
||||||
|
"Install Kimi hooks",
|
||||||
|
p.rename,
|
||||||
|
paths.runtimeRoot,
|
||||||
|
paths.agentDirectory,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf(
|
||||||
|
"%s: global PreToolUse, PostToolUse, and PostToolUseFailure hooks installed.\n",
|
||||||
|
kimiRuntimeNames(documents),
|
||||||
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,63 +91,51 @@ func (p *KimiPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mutations := make([]kimiMutation, 0, len(documents))
|
rendered := make([]kimiRenderedDocument, 0, len(documents))
|
||||||
for _, document := range documents {
|
for _, document := range documents {
|
||||||
keep := keptKimiHookIndices(document.hooks, agentID)
|
keep, err := keptKimiHookIndices(document.hooks, agentID)
|
||||||
if len(keep) == len(document.hooks) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
raw, err := renderKimiHooks(document, keep, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := parseKimiDocument(document.contract, raw, true); err != nil {
|
change, err := renderKimiChange(document, keep, nil)
|
||||||
return fmt.Errorf("validate rendered %s: %w", document.contract.label, err)
|
if err != nil {
|
||||||
}
|
|
||||||
mutations = append(mutations, kimiMutation{
|
|
||||||
document: document,
|
|
||||||
raw: raw,
|
|
||||||
remove: strings.TrimSpace(string(raw)) == "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, mutation := range mutations {
|
|
||||||
if mutation.remove {
|
|
||||||
if err := removeKimiConfig(mutation.document); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
continue
|
rendered = append(rendered, change)
|
||||||
}
|
}
|
||||||
if err := writeKimiConfig(mutation.document, mutation.raw); err != nil {
|
changes, err := prepareKimiUninstallChanges(rendered)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
return writeKimiChanges(
|
||||||
return nil
|
changes,
|
||||||
|
"Uninstall Kimi hooks",
|
||||||
|
p.rename,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *KimiPlugin) Status() (PluginStatus, error) {
|
func (p *KimiPlugin) Status() (PluginStatus, error) {
|
||||||
documents, err := readAllKimiConfigs()
|
documents, err := readAllKimiConfigs()
|
||||||
|
entry := SupportedAgents[kimiAgentKey]
|
||||||
status := PluginStatus{
|
status := PluginStatus{
|
||||||
AgentName: kimiAgentKey,
|
AgentName: kimiAgentKey, DisplayName: entry.Name,
|
||||||
DisplayName: "Kimi Code",
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
status.ConfigPath = documents[0].contract.configPath
|
status.ConfigPath = documents[0].contract.configPath
|
||||||
contracts := make([]kimiRuntimeContract, 0, len(documents))
|
contracts, err := kimiRuntimeContracts(documents)
|
||||||
for _, document := range documents {
|
if err != nil {
|
||||||
contract := kimiRuntimeForDocument(document)
|
return status, err
|
||||||
if contract == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
contracts = append(contracts, *contract)
|
|
||||||
status.ConfigPath = contract.configPath
|
|
||||||
}
|
}
|
||||||
if len(contracts) == 0 {
|
if len(contracts) == 0 {
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
status.ConfigPath = contracts[len(contracts)-1].configPath
|
||||||
status.HookConfigured = true
|
status.HookConfigured = true
|
||||||
status.HookScriptExists, err = kimiRuntimeScriptsExist(contracts)
|
status.HookScriptExists, err = kimiRuntimeFilesExist(contracts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type kimiRuntimeReference struct {
|
||||||
|
agentID string
|
||||||
|
scriptPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameKimiPath(left, right string) bool {
|
||||||
|
left = filepath.Clean(left)
|
||||||
|
right = filepath.Clean(right)
|
||||||
|
if absolute, err := filepath.Abs(left); err == nil {
|
||||||
|
left = absolute
|
||||||
|
}
|
||||||
|
if absolute, err := filepath.Abs(right); err == nil {
|
||||||
|
right = absolute
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return strings.EqualFold(left, right)
|
||||||
|
}
|
||||||
|
return left == right
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameKimiAgentID(left, right string) bool {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return strings.EqualFold(left, right)
|
||||||
|
}
|
||||||
|
return left == right
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKimiCommand(runtimePath, scriptPath string) (string, error) {
|
||||||
|
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
|
||||||
|
return "", fmt.Errorf("kimi hook commands require absolute runtime and script paths")
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return codexWindowsCommand(runtimePath, scriptPath), nil
|
||||||
|
}
|
||||||
|
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool) {
|
||||||
|
if start >= len(command) {
|
||||||
|
return "", start, false
|
||||||
|
}
|
||||||
|
if command[start] == '"' {
|
||||||
|
closing := strings.IndexByte(command[start+1:], '"')
|
||||||
|
if closing < 0 {
|
||||||
|
return "", start, false
|
||||||
|
}
|
||||||
|
closing += start + 1
|
||||||
|
return command[start+1 : closing], closing + 1, true
|
||||||
|
}
|
||||||
|
end := start
|
||||||
|
for end < len(command) && command[end] != ' ' {
|
||||||
|
if command[end] == '"' || command[end] == '\t' || command[end] == '\r' || command[end] == '\n' {
|
||||||
|
return "", start, false
|
||||||
|
}
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
return command[start:end], end, end > start
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKimiLegacyWindowsCommand(command string) (string, string, bool) {
|
||||||
|
runtimePath, next, ok := readKimiLegacyWindowsArgument(command, 0)
|
||||||
|
if !ok || next >= len(command) || command[next] != ' ' {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
scriptPath, end, ok := readKimiLegacyWindowsArgument(command, next+1)
|
||||||
|
if !ok || end != len(command) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
expected := quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath)
|
||||||
|
return runtimePath, scriptPath, command == expected
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKimiCommand(command string) (string, string, bool) {
|
||||||
|
if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok {
|
||||||
|
return runtimePath, scriptPath, true
|
||||||
|
}
|
||||||
|
if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok {
|
||||||
|
return runtimePath, scriptPath, true
|
||||||
|
}
|
||||||
|
return parseKimiLegacyWindowsCommand(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKimiNodeExecutable(path string) bool {
|
||||||
|
name := filepath.Base(path)
|
||||||
|
return name == "node" || strings.EqualFold(name, "node.exe")
|
||||||
|
}
|
||||||
|
|
||||||
|
func kimiRuntimeReferenceForCommand(
|
||||||
|
command string,
|
||||||
|
scriptName string,
|
||||||
|
) (*kimiRuntimeReference, error) {
|
||||||
|
runtimePath, scriptPath, ok := parseKimiCommand(command)
|
||||||
|
if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) ||
|
||||||
|
!isKimiNodeExecutable(runtimePath) || filepath.Base(scriptPath) != scriptName {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
agentDirectory := filepath.Dir(scriptPath)
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
agentID := filepath.Base(agentDirectory)
|
||||||
|
if agentID == "" || agentID == "." || agentID == ".." {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &kimiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -38,8 +37,10 @@ var kimiModernEvents = mergeStringSets(kimiSharedEvents, stringSet(
|
|||||||
))
|
))
|
||||||
|
|
||||||
type kimiContract struct {
|
type kimiContract struct {
|
||||||
|
generation string
|
||||||
runtimeName string
|
runtimeName string
|
||||||
label string
|
label string
|
||||||
|
directoryLabel string
|
||||||
configPath string
|
configPath string
|
||||||
events map[string]struct{}
|
events map[string]struct{}
|
||||||
}
|
}
|
||||||
@@ -49,11 +50,13 @@ type kimiHook struct {
|
|||||||
matcher *string
|
matcher *string
|
||||||
command string
|
command string
|
||||||
timeout *int64
|
timeout *int64
|
||||||
|
fieldCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
type kimiRuntimeContract struct {
|
type kimiRuntimeContract struct {
|
||||||
guard string
|
agentID string
|
||||||
audit string
|
guardPath string
|
||||||
|
auditPath string
|
||||||
configPath string
|
configPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +115,7 @@ func validateKimiHook(value any, contract kimiContract, index int) (kimiHook, er
|
|||||||
return kimiHook{}, fmt.Errorf("%s hook %d requires a non-empty command", contract.label, index+1)
|
return kimiHook{}, fmt.Errorf("%s hook %d requires a non-empty command", contract.label, index+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
hook := kimiHook{event: event, command: command}
|
hook := kimiHook{event: event, command: command, fieldCount: len(object)}
|
||||||
if value, exists := object["matcher"]; exists {
|
if value, exists := object["matcher"]; exists {
|
||||||
matcher, ok := value.(string)
|
matcher, ok := value.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -154,59 +157,124 @@ func kimiHooks(root map[string]any, contract kimiContract) ([]kimiHook, error) {
|
|||||||
return hooks, nil
|
return hooks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeKimiPath(value string) string {
|
func buildKimiHook(event, command string) (kimiHook, error) {
|
||||||
normalized := filepath.ToSlash(value)
|
switch event {
|
||||||
if runtime.GOOS == "windows" {
|
case "PreToolUse", "PostToolUse", "PostToolUseFailure":
|
||||||
return strings.ToLower(normalized)
|
default:
|
||||||
|
return kimiHook{}, fmt.Errorf("unsupported managed Kimi event: %s", event)
|
||||||
}
|
}
|
||||||
return normalized
|
timeout := kimiHookTimeoutSeconds
|
||||||
|
return kimiHook{
|
||||||
|
event: event, command: command, timeout: &timeout, fieldCount: 3,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func kimiCommandEndsWithPath(command, filePath string) bool {
|
func managedKimiReference(
|
||||||
normalized := strings.TrimSpace(normalizeKimiPath(command))
|
hook kimiHook,
|
||||||
expected := normalizeKimiPath(filePath)
|
event string,
|
||||||
return strings.HasSuffix(normalized, expected) ||
|
scriptName string,
|
||||||
strings.HasSuffix(normalized, expected+`'`) ||
|
) (*kimiRuntimeReference, error) {
|
||||||
strings.HasSuffix(normalized, expected+`"`)
|
if hook.fieldCount != 3 || hook.event != event || hook.matcher != nil ||
|
||||||
|
hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return kimiRuntimeReferenceForCommand(hook.command, scriptName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isManagedKimiHook(hook kimiHook, event, scriptName, agentID string) bool {
|
func managedKimiEvent(event string) (string, string, bool) {
|
||||||
if hook.event != event || hook.matcher != nil || hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds {
|
switch event {
|
||||||
return false
|
case "PreToolUse":
|
||||||
|
return event, kimiGuardScript, true
|
||||||
|
case "PostToolUse", "PostToolUseFailure":
|
||||||
|
return event, kimiAuditScript, true
|
||||||
|
default:
|
||||||
|
return "", "", false
|
||||||
}
|
}
|
||||||
if agentID != "" {
|
|
||||||
expected := normalizeKimiPath(filepath.Join(".elydora", agentID, scriptName))
|
|
||||||
return kimiCommandEndsWithPath(hook.command, "/"+expected)
|
|
||||||
}
|
|
||||||
normalized := normalizeKimiPath(hook.command)
|
|
||||||
return strings.Contains(normalized, "/.elydora/") &&
|
|
||||||
kimiCommandEndsWithPath(hook.command, "/"+normalizeKimiPath(scriptName))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func keptKimiHookIndices(hooks []kimiHook, agentID string) []int {
|
func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) {
|
||||||
indices := make([]int, 0, len(hooks))
|
indices := make([]int, 0, len(hooks))
|
||||||
for index, hook := range hooks {
|
for index, hook := range hooks {
|
||||||
managed := isManagedKimiHook(hook, "PreToolUse", kimiGuardScript, agentID) ||
|
event, scriptName, managedEvent := managedKimiEvent(hook.event)
|
||||||
isManagedKimiHook(hook, "PostToolUse", kimiAuditScript, agentID)
|
var reference *kimiRuntimeReference
|
||||||
if !managed {
|
var err error
|
||||||
|
if managedEvent {
|
||||||
|
reference, err = managedKimiReference(hook, event, scriptName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remove := reference != nil &&
|
||||||
|
(agentID == "" || sameKimiAgentID(reference.agentID, agentID))
|
||||||
|
if !remove {
|
||||||
indices = append(indices, index)
|
indices = append(indices, index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return indices
|
return indices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func kimiRuntimeForDocument(document kimiDocument) *kimiRuntimeContract {
|
func kimiReferenceKey(agentID string) string {
|
||||||
var guard, audit string
|
if runtime.GOOS == "windows" {
|
||||||
for _, hook := range document.hooks {
|
return strings.ToLower(agentID)
|
||||||
if isManagedKimiHook(hook, "PreToolUse", kimiGuardScript, "") {
|
|
||||||
guard = hook.command
|
|
||||||
}
|
}
|
||||||
if isManagedKimiHook(hook, "PostToolUse", kimiAuditScript, "") {
|
return agentID
|
||||||
audit = hook.command
|
}
|
||||||
|
|
||||||
|
func kimiReferencesForEvent(
|
||||||
|
hooks []kimiHook,
|
||||||
|
event string,
|
||||||
|
scriptName string,
|
||||||
|
) (map[string][]kimiRuntimeReference, error) {
|
||||||
|
result := map[string][]kimiRuntimeReference{}
|
||||||
|
for _, hook := range hooks {
|
||||||
|
reference, err := managedKimiReference(hook, event, scriptName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if reference == nil {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if guard == "" || audit == "" {
|
key := kimiReferenceKey(reference.agentID)
|
||||||
return nil
|
result[key] = append(result[key], *reference)
|
||||||
}
|
}
|
||||||
return &kimiRuntimeContract{guard: guard, audit: audit, configPath: document.contract.configPath}
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kimiRuntimeContracts(documents []kimiDocument) ([]kimiRuntimeContract, error) {
|
||||||
|
contracts := make([]kimiRuntimeContract, 0)
|
||||||
|
for _, document := range documents {
|
||||||
|
guards, err := kimiReferencesForEvent(document.hooks, "PreToolUse", kimiGuardScript)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
successes, err := kimiReferencesForEvent(document.hooks, "PostToolUse", kimiAuditScript)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
failures, err := kimiReferencesForEvent(
|
||||||
|
document.hooks, "PostToolUseFailure", kimiAuditScript,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(guards))
|
||||||
|
for key := range guards {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
guard := guards[key]
|
||||||
|
success := successes[key]
|
||||||
|
failure := failures[key]
|
||||||
|
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||||
|
!sameKimiPath(success[0].scriptPath, failure[0].scriptPath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contracts = append(contracts, kimiRuntimeContract{
|
||||||
|
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
||||||
|
auditPath: success[0].scriptPath, configPath: document.contract.configPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contracts, nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type kimiRuntimePaths struct {
|
||||||
|
runtimeRoot string
|
||||||
|
agentDirectory string
|
||||||
|
configPath string
|
||||||
|
keyPath string
|
||||||
|
guardPath string
|
||||||
|
auditPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type kimiRenderedDocument struct {
|
||||||
|
document kimiDocument
|
||||||
|
next []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateKimiInstallConfig(config InstallConfig) error {
|
||||||
|
for _, field := range []struct{ name, value string }{
|
||||||
|
{"agent name", config.AgentName},
|
||||||
|
{"organization ID", config.OrgID},
|
||||||
|
{"agent ID", config.AgentID},
|
||||||
|
{"key ID", config.KID},
|
||||||
|
{"private key", config.PrivateKey},
|
||||||
|
{"base URL", config.BaseURL},
|
||||||
|
{"guard script path", config.GuardScriptPath},
|
||||||
|
} {
|
||||||
|
if field.value == "" {
|
||||||
|
return fmt.Errorf("%s is required", field.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.AgentName != kimiAgentKey {
|
||||||
|
return fmt.Errorf("kimi installation requires agent name %s", kimiAgentKey)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.OrgID) == "" {
|
||||||
|
return fmt.Errorf("organization ID is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.KID) == "" {
|
||||||
|
return fmt.Errorf("key ID is required")
|
||||||
|
}
|
||||||
|
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
||||||
|
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
||||||
|
}
|
||||||
|
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateManagedBaseURL(config.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func kimiAgentPaths(config InstallConfig) (*kimiRuntimePaths, error) {
|
||||||
|
if err := validateKimiInstallConfig(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paths := &kimiRuntimePaths{
|
||||||
|
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
||||||
|
configPath: filepath.Join(agentDirectory, "config.json"),
|
||||||
|
keyPath: filepath.Join(agentDirectory, "private.key"),
|
||||||
|
guardPath: filepath.Join(agentDirectory, kimiGuardScript),
|
||||||
|
auditPath: filepath.Join(agentDirectory, kimiAuditScript),
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(config.GuardScriptPath) ||
|
||||||
|
!sameKimiPath(config.GuardScriptPath, paths.guardPath) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"guard runtime must use the managed Elydora agent directory: %s",
|
||||||
|
paths.guardPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
||||||
|
!sameKimiPath(config.HookScript, paths.auditPath)) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"audit runtime must use the managed Elydora agent directory: %s",
|
||||||
|
paths.auditPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func preflightKimiInstallation(
|
||||||
|
config InstallConfig,
|
||||||
|
documents []kimiDocument,
|
||||||
|
) (*kimiRuntimePaths, string, error) {
|
||||||
|
if len(documents) == 0 {
|
||||||
|
return nil, "", fmt.Errorf("kimi installation requires at least one hook contract")
|
||||||
|
}
|
||||||
|
paths, err := kimiAgentPaths(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := validateKimiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
nodePath, err := resolveNodeRuntime()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return paths, nodePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKimiRuntimeConfig(config InstallConfig) ([]byte, error) {
|
||||||
|
value := map[string]any{
|
||||||
|
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
||||||
|
"base_url": config.BaseURL, "agent_name": kimiAgentKey,
|
||||||
|
}
|
||||||
|
if config.Token != "" {
|
||||||
|
value["token"] = config.Token
|
||||||
|
}
|
||||||
|
encoded, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
||||||
|
}
|
||||||
|
encoded = append(encoded, '\n')
|
||||||
|
if len(encoded) > maxRuntimeConfigBytes {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"elydora runtime config exceeds %d bytes after JSON encoding",
|
||||||
|
maxRuntimeConfigBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return encoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderKimiChange(
|
||||||
|
document kimiDocument,
|
||||||
|
keep []int,
|
||||||
|
additions []kimiHook,
|
||||||
|
) (kimiRenderedDocument, error) {
|
||||||
|
next, err := renderKimiHooks(document, keep, additions)
|
||||||
|
if err != nil {
|
||||||
|
return kimiRenderedDocument{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(next)) != "" {
|
||||||
|
if _, err := parseKimiDocument(document.contract, next, true); err != nil {
|
||||||
|
return kimiRenderedDocument{}, fmt.Errorf(
|
||||||
|
"validate rendered %s: %w", document.contract.label, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kimiRenderedDocument{document: document, next: next}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareRenderedKimiChange(rendered kimiRenderedDocument) (*fileChange, error) {
|
||||||
|
document := rendered.document
|
||||||
|
if document.exists && bytes.Equal(document.raw, rendered.next) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
remove := document.exists && strings.TrimSpace(string(rendered.next)) == ""
|
||||||
|
return prepareSourceChange(
|
||||||
|
document.contract.configPath,
|
||||||
|
document.contract.label,
|
||||||
|
document.raw,
|
||||||
|
document.exists,
|
||||||
|
rendered.next,
|
||||||
|
0600,
|
||||||
|
remove,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareKimiInstallationChanges(
|
||||||
|
config InstallConfig,
|
||||||
|
paths *kimiRuntimePaths,
|
||||||
|
rendered []kimiRenderedDocument,
|
||||||
|
) ([]*fileChange, error) {
|
||||||
|
runtimeConfig, err := buildKimiRuntimeConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := []struct {
|
||||||
|
path, label string
|
||||||
|
content []byte
|
||||||
|
mode os.FileMode
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
paths.guardPath, "Elydora guard runtime",
|
||||||
|
[]byte(generateGuardScript(kimiAgentKey, config.AgentID, "", false)), 0700,
|
||||||
|
},
|
||||||
|
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
||||||
|
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
||||||
|
{
|
||||||
|
paths.auditPath, "Elydora audit runtime",
|
||||||
|
[]byte(buildHookScriptWithOutput(
|
||||||
|
kimiAgentKey, config.AgentID, "", false, true,
|
||||||
|
)), 0700,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
changes := make([]*fileChange, 0, len(items)+len(rendered))
|
||||||
|
for _, item := range items {
|
||||||
|
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
changes = append(changes, change)
|
||||||
|
}
|
||||||
|
for _, document := range rendered {
|
||||||
|
change, err := prepareRenderedKimiChange(document)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
changes = append(changes, change)
|
||||||
|
}
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareKimiUninstallChanges(
|
||||||
|
rendered []kimiRenderedDocument,
|
||||||
|
) ([]*fileChange, error) {
|
||||||
|
changes := make([]*fileChange, 0, len(rendered))
|
||||||
|
for _, document := range rendered {
|
||||||
|
change, err := prepareRenderedKimiChange(document)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
changes = append(changes, change)
|
||||||
|
}
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeKimiChanges(
|
||||||
|
changes []*fileChange,
|
||||||
|
label string,
|
||||||
|
rename renameFunc,
|
||||||
|
runtimeRoot string,
|
||||||
|
agentDirectory string,
|
||||||
|
) error {
|
||||||
|
hasChanges := false
|
||||||
|
for _, change := range changes {
|
||||||
|
if change != nil {
|
||||||
|
hasChanges = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasChanges {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if agentDirectory != "" {
|
||||||
|
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return writeChanges(changes, label, rename)
|
||||||
|
}
|
||||||
@@ -4,9 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
@@ -23,9 +21,8 @@ func isMissingKimiPath(err error) bool {
|
|||||||
return os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR)
|
return os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR)
|
||||||
}
|
}
|
||||||
|
|
||||||
func kimiPathExists(path, label string) (bool, error) {
|
func kimiEntryExists(path, label string) (bool, error) {
|
||||||
// #nosec G703 -- KIMI_CODE_HOME is an explicit local-user configuration path.
|
_, err := os.Lstat(path)
|
||||||
_, err := os.Stat(path)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -35,15 +32,21 @@ func kimiPathExists(path, label string) (bool, error) {
|
|||||||
return false, fmt.Errorf("inspect %s at %s: %w", label, path, err)
|
return false, fmt.Errorf("inspect %s at %s: %w", label, path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func legacyKimiCLIOnPath() (bool, error) {
|
func stableKimiContract(configPath string) kimiContract {
|
||||||
_, err := exec.LookPath("kimi-cli")
|
return kimiContract{
|
||||||
if err == nil {
|
generation: "stable", runtimeName: "Kimi Code",
|
||||||
return true, nil
|
label: "Kimi Code hooks config", directoryLabel: "Kimi Code home directory",
|
||||||
|
configPath: configPath, events: kimiModernEvents,
|
||||||
}
|
}
|
||||||
if errors.Is(err, exec.ErrNotFound) {
|
}
|
||||||
return false, nil
|
|
||||||
|
func legacyKimiContract(configPath string) kimiContract {
|
||||||
|
return kimiContract{
|
||||||
|
generation: "legacy", runtimeName: "kimi-cli",
|
||||||
|
label: "kimi-cli legacy hooks config",
|
||||||
|
directoryLabel: "kimi-cli legacy home directory",
|
||||||
|
configPath: configPath, events: kimiSharedEvents,
|
||||||
}
|
}
|
||||||
return false, fmt.Errorf("inspect kimi-cli executable on PATH: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveKimiContracts() ([]kimiContract, error) {
|
func resolveKimiContracts() ([]kimiContract, error) {
|
||||||
@@ -51,58 +54,59 @@ func resolveKimiContracts() ([]kimiContract, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
explicitHome := os.Getenv("KIMI_CODE_HOME")
|
configuredHome := os.Getenv("KIMI_CODE_HOME")
|
||||||
kimiHome := explicitHome
|
stableHome := filepath.Join(home, ".kimi-code")
|
||||||
if kimiHome == "" {
|
explicitHome := configuredHome != ""
|
||||||
kimiHome = filepath.Join(home, ".kimi-code")
|
if explicitHome {
|
||||||
|
stableHome, err = filepath.Abs(configuredHome)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve KIMI_CODE_HOME at %s: %w", configuredHome, err)
|
||||||
}
|
}
|
||||||
modern := kimiContract{
|
|
||||||
runtimeName: "Kimi Code",
|
|
||||||
label: "Kimi Code hooks config",
|
|
||||||
configPath: filepath.Join(kimiHome, "config.toml"),
|
|
||||||
events: kimiModernEvents,
|
|
||||||
}
|
}
|
||||||
legacy := kimiContract{
|
legacyHome := filepath.Join(home, ".kimi")
|
||||||
runtimeName: "kimi-cli",
|
stable := stableKimiContract(filepath.Join(stableHome, "config.toml"))
|
||||||
label: "kimi-cli legacy hooks config",
|
legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml"))
|
||||||
configPath: filepath.Join(home, ".kimi", "config.toml"),
|
if sameKimiPath(stable.configPath, legacy.configPath) {
|
||||||
events: kimiSharedEvents,
|
return []kimiContract{stable}, nil
|
||||||
}
|
}
|
||||||
modernDetected := explicitHome != ""
|
|
||||||
if !modernDetected {
|
stableDetected := explicitHome
|
||||||
modernDetected, err = kimiPathExists(kimiHome, "Kimi Code home")
|
if !stableDetected {
|
||||||
|
stableDetected, err = kimiEntryExists(stableHome, "Kimi Code home")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
legacyDetected, err := kimiPathExists(legacy.configPath, legacy.label)
|
legacyDetected, err := kimiEntryExists(legacyHome, "kimi-cli legacy home")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !legacyDetected {
|
if legacyDetected && !stableDetected {
|
||||||
legacyDetected, err = legacyKimiCLIOnPath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if legacyDetected && !modernDetected {
|
|
||||||
return []kimiContract{legacy}, nil
|
return []kimiContract{legacy}, nil
|
||||||
}
|
}
|
||||||
if legacyDetected {
|
if legacyDetected {
|
||||||
return []kimiContract{modern, legacy}, nil
|
return []kimiContract{stable, legacy}, nil
|
||||||
}
|
}
|
||||||
return []kimiContract{modern}, nil
|
return []kimiContract{stable}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readKimiConfig(contract kimiContract) (kimiDocument, error) {
|
func readKimiConfig(contract kimiContract) (kimiDocument, error) {
|
||||||
raw, err := os.ReadFile(contract.configPath)
|
directory := filepath.Dir(contract.configPath)
|
||||||
|
if _, err := managedPhysicalDirectoryExists(directory, contract.directoryLabel); err != nil {
|
||||||
|
return kimiDocument{}, err
|
||||||
|
}
|
||||||
|
snapshot, err := readManagedFile(
|
||||||
|
contract.configPath,
|
||||||
|
contract.label,
|
||||||
|
maxManagedSourceBytes,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
return kimiDocument{}, err
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
return parseKimiDocument(contract, nil, false)
|
return parseKimiDocument(contract, nil, false)
|
||||||
}
|
}
|
||||||
return kimiDocument{}, fmt.Errorf("read %s at %s: %w", contract.label, contract.configPath, err)
|
return parseKimiDocument(contract, snapshot.contents, true)
|
||||||
}
|
|
||||||
return parseKimiDocument(contract, raw, true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readAllKimiConfigs() ([]kimiDocument, error) {
|
func readAllKimiConfigs() ([]kimiDocument, error) {
|
||||||
@@ -121,32 +125,6 @@ func readAllKimiConfigs() ([]kimiDocument, error) {
|
|||||||
return documents, nil
|
return documents, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeKimiConfig(document kimiDocument, raw []byte) error {
|
|
||||||
if err := writeHookFileAtomic(document.contract.configPath, raw, 0600); err != nil {
|
|
||||||
return fmt.Errorf("write %s at %s: %w", document.contract.label, document.contract.configPath, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeKimiConfig(document kimiDocument) error {
|
|
||||||
if err := removeHookFile(document.contract.configPath, document.contract.label); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildKimiCommand(runtimePath, scriptPath string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath)
|
|
||||||
}
|
|
||||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildKimiHook(event, command string) kimiHook {
|
|
||||||
timeout := kimiHookTimeoutSeconds
|
|
||||||
return kimiHook{event: event, command: command, timeout: &timeout}
|
|
||||||
}
|
|
||||||
|
|
||||||
func kimiRuntimeNames(documents []kimiDocument) string {
|
func kimiRuntimeNames(documents []kimiDocument) string {
|
||||||
names := make([]string, 0, len(documents))
|
names := make([]string, 0, len(documents))
|
||||||
for _, document := range documents {
|
for _, document := range documents {
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func installKimiRuntimeFixture(
|
||||||
|
t *testing.T,
|
||||||
|
status string,
|
||||||
|
) (*kimiFixture, *codexTestAPI, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
api := &codexTestAPI{status: status}
|
||||||
|
server := httptest.NewServer(api)
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
fixture.config.BaseURL = server.URL
|
||||||
|
fixture.config.Token = "ely_test_token"
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
server.Close()
|
||||||
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
return fixture, api, server
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiGeneratedRuntimePreservesSuccessAndFailurePayloads(t *testing.T) {
|
||||||
|
fixture, api, server := installKimiRuntimeFixture(t, "active")
|
||||||
|
defer server.Close()
|
||||||
|
hooks := readKimiTestHooks(t, fixture.modernPath)
|
||||||
|
guard := managedKimiTestHook(t, hooks, "PreToolUse")["command"].(string)
|
||||||
|
success := managedKimiTestHook(t, hooks, "PostToolUse")["command"].(string)
|
||||||
|
failure := managedKimiTestHook(t, hooks, "PostToolUseFailure")["command"].(string)
|
||||||
|
|
||||||
|
if exitCode, stderr := runKimiCommand(
|
||||||
|
t, guard, fixture, kimiOfficialPayload("PreToolUse"),
|
||||||
|
); exitCode != 0 || stderr != "" {
|
||||||
|
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
|
||||||
|
}
|
||||||
|
successPayload := kimiOfficialPayload("PostToolUse")
|
||||||
|
failurePayload := kimiOfficialPayload("PostToolUseFailure")
|
||||||
|
for _, item := range []struct {
|
||||||
|
command string
|
||||||
|
payload map[string]any
|
||||||
|
}{{success, successPayload}, {failure, failurePayload}} {
|
||||||
|
if exitCode, stderr := runKimiCommand(
|
||||||
|
t, item.command, fixture, item.payload,
|
||||||
|
); exitCode != 0 || stderr != "" {
|
||||||
|
t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuth, postAuth, operations := api.snapshot()
|
||||||
|
if getAuth != "Bearer ely_test_token" || postAuth != "Bearer ely_test_token" {
|
||||||
|
t.Fatalf("authorization headers = %q, %q", getAuth, postAuth)
|
||||||
|
}
|
||||||
|
if len(operations) != 2 ||
|
||||||
|
!reflect.DeepEqual(operations[0]["payload"], successPayload) ||
|
||||||
|
!reflect.DeepEqual(operations[1]["payload"], failurePayload) {
|
||||||
|
t.Fatalf("Kimi native operations = %#v", operations)
|
||||||
|
}
|
||||||
|
if operations[0]["prev_chain_hash"] != "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ||
|
||||||
|
operations[1]["prev_chain_hash"] != operations[0]["chain_hash"] {
|
||||||
|
t.Fatalf("Kimi operation chain = %#v", operations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiGuardPropagatesOfficialBlockExitCode(t *testing.T) {
|
||||||
|
for _, status := range []string{"frozen", "revoked"} {
|
||||||
|
t.Run(status, func(t *testing.T) {
|
||||||
|
fixture, _, server := installKimiRuntimeFixture(t, status)
|
||||||
|
defer server.Close()
|
||||||
|
command := managedKimiTestHook(
|
||||||
|
t, readKimiTestHooks(t, fixture.modernPath), "PreToolUse",
|
||||||
|
)["command"].(string)
|
||||||
|
exitCode, stderr := runKimiCommand(
|
||||||
|
t, command, fixture, kimiOfficialPayload("PreToolUse"),
|
||||||
|
)
|
||||||
|
if exitCode != 2 || !strings.Contains(stderr, "Tool execution blocked") {
|
||||||
|
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiFailOpenGuardReportsInputConfigStatusAndAPIFailures(t *testing.T) {
|
||||||
|
fixture, api, server := installKimiRuntimeFixture(t, "active")
|
||||||
|
command := managedKimiTestHook(
|
||||||
|
t, readKimiTestHooks(t, fixture.modernPath), "PreToolUse",
|
||||||
|
)["command"].(string)
|
||||||
|
runtimeConfig, err := os.ReadFile(fixture.runtimeConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read runtime config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
malformedExit, malformedError := runKimiRawCommand(
|
||||||
|
t, command, fixture, []byte("{ malformed"),
|
||||||
|
)
|
||||||
|
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||||
|
t.Fatalf("corrupt runtime config: %v", err)
|
||||||
|
}
|
||||||
|
configExit, configError := runKimiCommand(
|
||||||
|
t, command, fixture, kimiOfficialPayload("PreToolUse"),
|
||||||
|
)
|
||||||
|
if err := os.WriteFile(fixture.runtimeConfig, runtimeConfig, 0600); err != nil {
|
||||||
|
t.Fatalf("restore runtime config: %v", err)
|
||||||
|
}
|
||||||
|
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
|
||||||
|
api.mu.Lock()
|
||||||
|
api.status = "unknown"
|
||||||
|
api.mu.Unlock()
|
||||||
|
statusExit, statusError := runKimiCommand(
|
||||||
|
t, command, fixture, kimiOfficialPayload("PreToolUse"),
|
||||||
|
)
|
||||||
|
_ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json"))
|
||||||
|
server.Close()
|
||||||
|
apiExit, apiError := runKimiCommand(
|
||||||
|
t, command, fixture, kimiOfficialPayload("PreToolUse"),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, result := range []struct {
|
||||||
|
exit int
|
||||||
|
err string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{malformedExit, malformedError, "invalid JSON"},
|
||||||
|
{configExit, configError, "Failed to read agent config"},
|
||||||
|
{statusExit, statusError, "invalid agent status"},
|
||||||
|
{apiExit, apiError, "Failed to resolve agent status"},
|
||||||
|
} {
|
||||||
|
if result.exit != 0 || !strings.Contains(result.err, result.want) {
|
||||||
|
t.Fatalf("fail-open result = %d, %q; want %q", result.exit, result.err, result.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiAuditRecordsMalformedInputAndAPIFailures(t *testing.T) {
|
||||||
|
fixture, api, server := installKimiRuntimeFixture(t, "active")
|
||||||
|
defer server.Close()
|
||||||
|
command := managedKimiTestHook(
|
||||||
|
t, readKimiTestHooks(t, fixture.modernPath), "PostToolUseFailure",
|
||||||
|
)["command"].(string)
|
||||||
|
|
||||||
|
malformedExit, malformedError := runKimiRawCommand(
|
||||||
|
t, command, fixture, []byte("{ malformed"),
|
||||||
|
)
|
||||||
|
api.mu.Lock()
|
||||||
|
api.postStatus = http.StatusServiceUnavailable
|
||||||
|
api.mu.Unlock()
|
||||||
|
upstreamExit, upstreamError := runKimiCommand(
|
||||||
|
t, command, fixture, kimiOfficialPayload("PostToolUseFailure"),
|
||||||
|
)
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if malformedExit != 0 || malformedError != "" || upstreamExit != 0 || upstreamError != "" ||
|
||||||
|
err != nil || !strings.Contains(string(log), "invalid JSON") ||
|
||||||
|
!strings.Contains(string(log), "HTTP 503") {
|
||||||
|
t.Fatalf(
|
||||||
|
"audit failures = (%d, %q), (%d, %q), log=%q, err=%v",
|
||||||
|
malformedExit, malformedError, upstreamExit, upstreamError, log, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiFailurePayloadIncludesOfficialErrorShape(t *testing.T) {
|
||||||
|
payload := kimiOfficialPayload("PostToolUseFailure")
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal payload: %v", err)
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
if decoded["hook_event_name"] != "PostToolUseFailure" || decoded["error"] == nil {
|
||||||
|
t.Fatalf("failure payload = %#v", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKimiRejectsLinkedConfigBeforeCreatingRuntime(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
target := filepath.Join(t.TempDir(), "config-target.toml")
|
||||||
|
source := []byte("# protected target\ntelemetry = false\n")
|
||||||
|
if err := os.WriteFile(target, source, 0600); err != nil {
|
||||||
|
t.Fatalf("write config target: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fixture.modernPath), 0700); err != nil {
|
||||||
|
t.Fatalf("create Kimi config directory: %v", err)
|
||||||
|
}
|
||||||
|
kimiSymlinkOrSkip(t, target, fixture.modernPath)
|
||||||
|
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "physical file") {
|
||||||
|
t.Fatalf("linked config error = %v", err)
|
||||||
|
}
|
||||||
|
actual, readErr := os.ReadFile(target)
|
||||||
|
if readErr != nil || string(actual) != string(source) {
|
||||||
|
t.Fatalf("linked config target changed: %q, %v", actual, readErr)
|
||||||
|
}
|
||||||
|
assertNoKimiRuntimeWrites(t, fixture)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiRejectsLinkedConfigAndRuntimeDirectories(t *testing.T) {
|
||||||
|
for _, kind := range []string{"config", "runtime"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
target := filepath.Join(t.TempDir(), kind+"-target")
|
||||||
|
if err := os.MkdirAll(target, 0700); err != nil {
|
||||||
|
t.Fatalf("create link target: %v", err)
|
||||||
|
}
|
||||||
|
link := fixture.kimiHome
|
||||||
|
if kind == "runtime" {
|
||||||
|
link = filepath.Dir(fixture.agentDir)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(link), 0700); err != nil {
|
||||||
|
t.Fatalf("create link parent: %v", err)
|
||||||
|
}
|
||||||
|
kimiSymlinkOrSkip(t, target, link)
|
||||||
|
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "physical directory") {
|
||||||
|
t.Fatalf("linked %s directory error = %v", kind, err)
|
||||||
|
}
|
||||||
|
entries, readErr := os.ReadDir(target)
|
||||||
|
if readErr != nil || len(entries) != 0 {
|
||||||
|
t.Fatalf("linked directory target changed: %#v, %v", entries, readErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiRejectsLinkedRuntimeFilesBeforeConfigWrites(t *testing.T) {
|
||||||
|
for _, name := range []string{"config", "key", "guard", "audit"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
path := map[string]string{
|
||||||
|
"config": fixture.runtimeConfig, "key": fixture.privateKey,
|
||||||
|
"guard": fixture.guardPath, "audit": fixture.hookPath,
|
||||||
|
}[name]
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||||
|
t.Fatalf("create runtime directory: %v", err)
|
||||||
|
}
|
||||||
|
target := filepath.Join(t.TempDir(), name+"-target")
|
||||||
|
source := []byte("external\n")
|
||||||
|
if err := os.WriteFile(target, source, 0600); err != nil {
|
||||||
|
t.Fatalf("write runtime target: %v", err)
|
||||||
|
}
|
||||||
|
kimiSymlinkOrSkip(t, target, path)
|
||||||
|
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "physical file") {
|
||||||
|
t.Fatalf("linked %s error = %v", name, err)
|
||||||
|
}
|
||||||
|
actual, readErr := os.ReadFile(target)
|
||||||
|
if readErr != nil || string(actual) != string(source) {
|
||||||
|
t.Fatalf("linked runtime target changed: %q, %v", actual, readErr)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(fixture.modernPath); !errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
t.Fatalf("Kimi config exists after rejection: %v", statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiRejectsOrphanAndMismatchedRuntimeIdentity(t *testing.T) {
|
||||||
|
for _, testCase := range []struct{ name, config string }{
|
||||||
|
{"orphan", ""},
|
||||||
|
{"mismatch", `{"agent_name":"kimi","agent_id":"another-agent"}`},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
|
||||||
|
t.Fatalf("create agent directory: %v", err)
|
||||||
|
}
|
||||||
|
if testCase.config == "" {
|
||||||
|
if err := os.WriteFile(fixture.guardPath, []byte("orphan\n"), 0700); err != nil {
|
||||||
|
t.Fatalf("write orphan guard: %v", err)
|
||||||
|
}
|
||||||
|
} else if err := os.WriteFile(
|
||||||
|
fixture.runtimeConfig, []byte(testCase.config), 0600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write mismatched config: %v", err)
|
||||||
|
}
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "runtime") {
|
||||||
|
t.Fatalf("runtime identity error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(fixture.modernPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("Kimi config exists after rejection: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiValidatesIdentityCredentialsAndOriginBeforeWrites(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*InstallConfig)
|
||||||
|
}{
|
||||||
|
{"agent name", func(config *InstallConfig) { config.AgentName = "codex" }},
|
||||||
|
{"organization", func(config *InstallConfig) { config.OrgID = " " }},
|
||||||
|
{"agent ID", func(config *InstallConfig) { config.AgentID = "../escape" }},
|
||||||
|
{"key ID", func(config *InstallConfig) { config.KID = "\t" }},
|
||||||
|
{"private key", func(config *InstallConfig) { config.PrivateKey = "invalid" }},
|
||||||
|
{"token", func(config *InstallConfig) { config.Token = " " }},
|
||||||
|
{"scheme", func(config *InstallConfig) { config.BaseURL = "file:///tmp/elydora" }},
|
||||||
|
{"credentials", func(config *InstallConfig) { config.BaseURL = "https://user:secret@api.elydora.com" }},
|
||||||
|
{"query", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com?tenant=one" }},
|
||||||
|
{"backslash", func(config *InstallConfig) { config.BaseURL = `https://api.elydora.com\evil` }},
|
||||||
|
{"space", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com/invalid path" }},
|
||||||
|
{"port", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com:invalid" }},
|
||||||
|
{"guard path", func(config *InstallConfig) { config.GuardScriptPath = "unmanaged.js" }},
|
||||||
|
{"audit path", func(config *InstallConfig) { config.HookScript = "unmanaged.js" }},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
testCase.mutate(&fixture.config)
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("install accepted invalid configuration")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(fixture.modernPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("Kimi config exists after validation failure: %v", err)
|
||||||
|
}
|
||||||
|
assertNoKimiRuntimeWrites(t, fixture)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiStatusRejectsLinkedRuntimeFiles(t *testing.T) {
|
||||||
|
for _, name := range []string{"config", "key", "guard", "audit"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
path := map[string]string{
|
||||||
|
"config": fixture.runtimeConfig, "key": fixture.privateKey,
|
||||||
|
"guard": fixture.guardPath, "audit": fixture.hookPath,
|
||||||
|
}[name]
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read runtime file: %v", err)
|
||||||
|
}
|
||||||
|
target := filepath.Join(t.TempDir(), name+"-target")
|
||||||
|
if err := os.WriteFile(target, content, 0600); err != nil {
|
||||||
|
t.Fatalf("write linked target: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
t.Fatalf("remove runtime file: %v", err)
|
||||||
|
}
|
||||||
|
kimiSymlinkOrSkip(t, target, path)
|
||||||
|
status, err := fixture.plugin.Status()
|
||||||
|
if err == nil || status.Installed {
|
||||||
|
t.Fatalf("linked runtime status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiStatusValidatesRuntimeMetadataAndKey(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(map[string]any)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"extra", func(value map[string]any) { value["extra"] = true }, "unsupported field"},
|
||||||
|
{"organization", func(value map[string]any) { value["org_id"] = "" }, "org_id is invalid"},
|
||||||
|
{"identity", func(value map[string]any) { value["agent_id"] = "other" }, "identity"},
|
||||||
|
{"base URL", func(value map[string]any) { value["base_url"] = "file:///tmp" }, "base URL"},
|
||||||
|
{"token", func(value map[string]any) { value["token"] = "" }, "token is invalid"},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(fixture.runtimeConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read runtime config: %v", err)
|
||||||
|
}
|
||||||
|
var value map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
t.Fatalf("decode runtime config: %v", err)
|
||||||
|
}
|
||||||
|
testCase.mutate(value)
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode runtime config: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fixture.runtimeConfig, encoded, 0600); err != nil {
|
||||||
|
t.Fatalf("write runtime config: %v", err)
|
||||||
|
}
|
||||||
|
status, err := fixture.plugin.Status()
|
||||||
|
if err == nil || status.Installed || !strings.Contains(err.Error(), testCase.want) {
|
||||||
|
t.Fatalf("invalid runtime status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
|
||||||
|
t.Fatalf("write invalid private key: %v", err)
|
||||||
|
}
|
||||||
|
if status, err := fixture.plugin.Status(); err == nil || status.Installed ||
|
||||||
|
!strings.Contains(err.Error(), "canonical 32-byte") {
|
||||||
|
t.Fatalf("invalid key status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiOwnershipRequiresExactSupportedFields(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
legacy := legacyKimiCommand(t, fixture.guardPath)
|
||||||
|
source := "[[hooks]]\nevent = \"PreToolUse\"\ncommand = " + strconv.Quote(legacy) +
|
||||||
|
"\ntimeout = 10\nmatcher = \"Bash\"\n"
|
||||||
|
writeOptionalKimiConfig(t, fixture.modernPath, kimiString(source))
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
hooks := readKimiTestHooks(t, fixture.modernPath)
|
||||||
|
if len(hooks) != 4 || hooks[0]["matcher"] != "Bash" {
|
||||||
|
t.Fatalf("user-owned matcher hook changed: %#v", hooks)
|
||||||
|
}
|
||||||
|
requireKimiManagedTriple(t, hooks)
|
||||||
|
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||||
|
t.Fatalf("uninstall Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
hooks = readKimiTestHooks(t, fixture.modernPath)
|
||||||
|
if len(hooks) != 1 || hooks[0]["matcher"] != "Bash" {
|
||||||
|
t.Fatalf("user-owned matcher hook removed: %#v", hooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiStatusSurfacesMalformedRuntimeMetadata(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||||
|
t.Fatalf("corrupt runtime config: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fixture.plugin.Status(); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "parse Elydora runtime config") {
|
||||||
|
t.Fatalf("status error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,70 +2,194 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func kimiRuntimeScriptsExist(contracts []kimiRuntimeContract) (bool, error) {
|
func readKimiRuntimeConfig(path string) (map[string]any, bool, error) {
|
||||||
home, err := kimiHomeDirectory()
|
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
||||||
|
if err != nil || snapshot == nil {
|
||||||
|
return nil, snapshot != nil, err
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
||||||
|
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
||||||
|
return config, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireKimiRuntimeString(config map[string]any, field, configPath string) (string, error) {
|
||||||
|
value, ok := config[field].(string)
|
||||||
|
if !ok || strings.TrimSpace(value) == "" {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"elydora runtime config %s is invalid: %s", field, configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateKimiRuntimeConfig(
|
||||||
|
config map[string]any,
|
||||||
|
expectedAgentID string,
|
||||||
|
configPath string,
|
||||||
|
) error {
|
||||||
|
supported := stringSet("org_id", "agent_id", "kid", "base_url", "token", "agent_name")
|
||||||
|
fields := make([]string, 0, len(config))
|
||||||
|
for field := range config {
|
||||||
|
fields = append(fields, field)
|
||||||
|
}
|
||||||
|
sort.Strings(fields)
|
||||||
|
for _, field := range fields {
|
||||||
|
if _, ok := supported[field]; !ok {
|
||||||
|
return fmt.Errorf(
|
||||||
|
`elydora runtime config has unsupported field %q: %s`, field, configPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := requireKimiRuntimeString(config, "org_id", configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := requireKimiRuntimeString(config, "kid", configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
agentID, err := requireKimiRuntimeString(config, "agent_id", configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
root := filepath.Join(home, ".elydora")
|
if !sameKimiAgentID(agentID, expectedAgentID) || config["agent_name"] != kimiAgentKey {
|
||||||
entries, err := os.ReadDir(root)
|
return fmt.Errorf("elydora runtime identity does not match Kimi hooks: %s", configPath)
|
||||||
|
}
|
||||||
|
if _, exists := config["token"]; exists {
|
||||||
|
if _, err := requireKimiRuntimeString(config, "token", configPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseURL, err := requireKimiRuntimeString(config, "base_url", configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
return err
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
return false, fmt.Errorf("read Elydora runtime directory at %s: %w", root, err)
|
if err := validateManagedBaseURL(baseURL); err != nil {
|
||||||
|
return fmt.Errorf("elydora runtime config base URL is invalid at %s: %w", configPath, err)
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
return nil
|
||||||
if !entry.IsDir() {
|
}
|
||||||
continue
|
|
||||||
|
func validateKimiRuntimeIdentity(agentDirectory, agentID string) error {
|
||||||
|
runtimeRoot := filepath.Dir(agentDirectory)
|
||||||
|
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
|
||||||
|
if err != nil || !rootExists {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
agentDir := filepath.Join(root, entry.Name())
|
directoryExists, err := managedPhysicalDirectoryExists(
|
||||||
guardPath := filepath.Join(agentDir, kimiGuardScript)
|
agentDirectory, "Elydora agent runtime directory",
|
||||||
auditPath := filepath.Join(agentDir, kimiAuditScript)
|
)
|
||||||
if !kimiContractsReferenceRuntime(contracts, guardPath, auditPath) {
|
if err != nil || !directoryExists {
|
||||||
continue
|
return err
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(agentDir, "config.json")
|
configPath := filepath.Join(agentDirectory, "config.json")
|
||||||
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
|
config, configExists, err := readKimiRuntimeConfig(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
if !exists {
|
artifactExists := false
|
||||||
continue
|
for _, item := range []struct {
|
||||||
|
path string
|
||||||
|
label string
|
||||||
|
limit int64
|
||||||
|
}{
|
||||||
|
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
||||||
|
{filepath.Join(agentDirectory, kimiGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
||||||
|
{filepath.Join(agentDirectory, kimiAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
||||||
|
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
||||||
|
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
||||||
|
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
||||||
|
} {
|
||||||
|
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
||||||
|
if inspectErr != nil {
|
||||||
|
return inspectErr
|
||||||
}
|
}
|
||||||
agentName, ok := config["agent_name"].(string)
|
artifactExists = artifactExists || exists
|
||||||
if !ok {
|
}
|
||||||
return false, fmt.Errorf(
|
if !configExists {
|
||||||
`Elydora runtime config at %s field "agent_name" must be a string`,
|
if artifactExists {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"elydora runtime identity cannot be verified without config.json: %s",
|
||||||
|
agentDirectory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
configuredID, ok := config["agent_id"].(string)
|
||||||
|
if !ok || config["agent_name"] != kimiAgentKey ||
|
||||||
|
!sameKimiAgentID(configuredID, agentID) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"elydora runtime config identity does not match Kimi agent %s: %s",
|
||||||
|
agentID,
|
||||||
configPath,
|
configPath,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if agentName != kimiAgentKey {
|
return nil
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
guardExists, err := regularFileExists(guardPath, "Elydora guard runtime")
|
func kimiRuntimeContractExists(contract kimiRuntimeContract) (bool, error) {
|
||||||
|
runtimeRoot, err := AgentRuntimeRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
auditExists, err := regularFileExists(auditPath, "Elydora audit runtime")
|
agentDirectory := filepath.Dir(contract.guardPath)
|
||||||
|
if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
||||||
|
!sameKimiPath(contract.auditPath, filepath.Join(agentDirectory, kimiAuditScript)) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
|
||||||
|
if err != nil || !rootExists {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
directoryExists, err := managedPhysicalDirectoryExists(
|
||||||
|
agentDirectory, "Elydora agent runtime directory",
|
||||||
|
)
|
||||||
|
if err != nil || !directoryExists {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(agentDirectory, "config.json")
|
||||||
|
keyPath := filepath.Join(agentDirectory, "private.key")
|
||||||
|
config, configExists, err := readKimiRuntimeConfig(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return guardExists && auditExists, nil
|
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !configExists || key == nil || guard == nil || audit == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := validateKimiRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
||||||
|
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
|
||||||
|
}
|
||||||
|
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kimiRuntimeFilesExist(contracts []kimiRuntimeContract) (bool, error) {
|
||||||
|
for _, contract := range contracts {
|
||||||
|
exists, err := kimiRuntimeContractExists(contract)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func kimiContractsReferenceRuntime(contracts []kimiRuntimeContract, guardPath, auditPath string) bool {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
if kimiCommandEndsWithPath(contract.guard, guardPath) &&
|
|
||||||
kimiCommandEndsWithPath(contract.audit, auditPath) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
+280
-260
@@ -2,71 +2,90 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestKimiRegistryUsesModernGlobalContract(t *testing.T) {
|
func TestKimiRegistryUsesStableGlobalContract(t *testing.T) {
|
||||||
entry := SupportedAgents[kimiAgentKey]
|
entry := SupportedAgents[kimiAgentKey]
|
||||||
if entry.Name != "Kimi Code" || entry.ConfigDir != "~/.kimi-code" || entry.ConfigFile != "config.toml" {
|
if entry.Name != "Kimi Code" || entry.ConfigDir != "~/.kimi-code" ||
|
||||||
|
entry.ConfigFile != "config.toml" {
|
||||||
t.Fatalf("Kimi registry entry = %#v", entry)
|
t.Fatalf("Kimi registry entry = %#v", entry)
|
||||||
}
|
}
|
||||||
if _, ok := NewPlugin(kimiAgentKey).(*KimiPlugin); !ok {
|
plugin, ok := NewPlugin(kimiAgentKey).(*KimiPlugin)
|
||||||
|
if !ok || !plugin.ManagesGuardRuntime() {
|
||||||
t.Fatalf("Kimi registry plugin = %T", NewPlugin(kimiAgentKey))
|
t.Fatalf("Kimi registry plugin = %T", NewPlugin(kimiAgentKey))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKimiInstallPreservesBothConfigsAndIsIdempotent(t *testing.T) {
|
func TestKimiInstallPreservesBothConfigsAndIsIdempotent(t *testing.T) {
|
||||||
modern := "# modern user config\ndefault_model = \"kimi-code/k3\"\n\n" +
|
modern := "# stable user config\ndefault_model = \"kimi-code/k3\"\n\n" +
|
||||||
"[[hooks]]\nevent = \"SessionStart\"\ncommand = \"existing-modern\"\n" +
|
"[[hooks]]\nevent = \"SessionStart\"\ncommand = \"existing-stable\"\n" +
|
||||||
"timeout = 30 # keep modern hook\n"
|
"timeout = 30 # keep stable hook\n"
|
||||||
legacy := "# legacy user config\ntelemetry = false\n\n" +
|
legacy := "# legacy user config\ntelemetry = false\n\n" +
|
||||||
"[[hooks]]\nevent = \"SessionEnd\"\ncommand = \"existing-legacy\"\n"
|
"[[hooks]]\nevent = \"SessionEnd\"\ncommand = \"existing-legacy\"\n"
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
modernConfig: kimiString(modern),
|
modernConfig: kimiString(modern), legacyConfig: kimiString(legacy),
|
||||||
legacyConfig: kimiString(legacy),
|
|
||||||
})
|
})
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
}
|
}
|
||||||
|
first := map[string][]byte{}
|
||||||
|
for _, path := range []string{fixture.modernPath, fixture.legacyPath} {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read first Kimi source: %v", err)
|
||||||
|
}
|
||||||
|
first[path] = raw
|
||||||
|
}
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("repeat Kimi install: %v", err)
|
t.Fatalf("repeat Kimi install: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, contract := range []struct{ path, comment, command string }{
|
for _, contract := range []struct{ path, comment, command string }{
|
||||||
{fixture.modernPath, "# modern user config", "existing-modern"},
|
{fixture.modernPath, "# stable user config", "existing-stable"},
|
||||||
{fixture.legacyPath, "# legacy user config", "existing-legacy"},
|
{fixture.legacyPath, "# legacy user config", "existing-legacy"},
|
||||||
} {
|
} {
|
||||||
raw, err := os.ReadFile(contract.path)
|
raw, err := os.ReadFile(contract.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read Kimi config: %v", err)
|
t.Fatalf("read Kimi config: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(raw), contract.comment) || !strings.Contains(string(raw), contract.command) {
|
if !reflect.DeepEqual(raw, first[contract.path]) ||
|
||||||
|
!strings.Contains(string(raw), contract.comment) ||
|
||||||
|
!strings.Contains(string(raw), contract.command) {
|
||||||
t.Fatalf("user config changed: %s", raw)
|
t.Fatalf("user config changed: %s", raw)
|
||||||
}
|
}
|
||||||
hooks := readKimiTestHooks(t, contract.path)
|
hooks := readKimiTestHooks(t, contract.path)
|
||||||
if len(hooks) != 3 {
|
if len(hooks) != 4 {
|
||||||
t.Fatalf("hooks = %#v, want three entries", hooks)
|
t.Fatalf("hooks = %#v, want four entries", hooks)
|
||||||
}
|
}
|
||||||
requireStrictKimiHook(t, findKimiTestHook(t, hooks, "PreToolUse", kimiGuardScript))
|
requireKimiManagedTriple(t, hooks)
|
||||||
requireStrictKimiHook(t, findKimiTestHook(t, hooks, "PostToolUse", kimiAuditScript))
|
}
|
||||||
|
defaultStable := filepath.Join(fixture.homeDir, ".kimi-code", "config.toml")
|
||||||
|
if _, err := os.Stat(defaultStable); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("unselected default config exists: %v", err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath,
|
||||||
|
} {
|
||||||
|
if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() {
|
||||||
|
t.Fatalf("managed runtime missing at %s: %v", path, err)
|
||||||
}
|
}
|
||||||
defaultModern := filepath.Join(fixture.homeDir, ".kimi-code", "config.toml")
|
|
||||||
if _, err := os.Stat(defaultModern); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("false default migration target exists: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKimiInstallPreservesInlineHookArrayStyle(t *testing.T) {
|
func TestKimiInstallPreservesInlineHookArrayStyle(t *testing.T) {
|
||||||
modern := "# inline user hook\nhooks = [\n # keep leading\n { event = \"SessionStart\", " +
|
modern := "# inline user hook\nhooks = [\n # keep leading\n" +
|
||||||
"command = \"existing-inline\" }, # keep gap\n] # keep array\n"
|
" { event = \"SessionStart\", command = \"existing-inline\" }, # keep gap\n" +
|
||||||
|
"] # keep array\n"
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
modernConfig: kimiString(modern),
|
modernConfig: kimiString(modern), withoutLegacyEvidence: true,
|
||||||
withoutLegacyCLI: true,
|
|
||||||
})
|
})
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install inline Kimi hooks: %v", err)
|
t.Fatalf("install inline Kimi hooks: %v", err)
|
||||||
@@ -79,150 +98,181 @@ func TestKimiInstallPreservesInlineHookArrayStyle(t *testing.T) {
|
|||||||
t.Fatalf("read inline Kimi config: %v", err)
|
t.Fatalf("read inline Kimi config: %v", err)
|
||||||
}
|
}
|
||||||
for _, marker := range []string{
|
for _, marker := range []string{
|
||||||
"# inline user hook",
|
"# inline user hook", "# keep leading", "# keep gap", "# keep array", "hooks = [",
|
||||||
"# keep leading",
|
|
||||||
"# keep gap",
|
|
||||||
"# keep array",
|
|
||||||
"hooks = [",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(string(raw), marker) {
|
if !strings.Contains(string(raw), marker) {
|
||||||
t.Fatalf("inline marker %q missing from %s", marker, raw)
|
t.Fatalf("inline marker %q missing from %s", marker, raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(readKimiTestHooks(t, fixture.modernPath)) != 3 {
|
hooks := readKimiTestHooks(t, fixture.modernPath)
|
||||||
t.Fatalf("inline Kimi hooks are not idempotent: %s", raw)
|
if len(hooks) != 4 {
|
||||||
|
t.Fatalf("inline hooks = %#v", hooks)
|
||||||
}
|
}
|
||||||
|
requireKimiManagedTriple(t, hooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKimiModernDefaultAvoidsLegacyMigrationMarker(t *testing.T) {
|
func TestKimiUninstallPreservesUserOwnedEmptyInlineArray(t *testing.T) {
|
||||||
|
source := "# user container\nhooks = [] # keep empty array\n"
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
useDefaultHome: true,
|
modernConfig: kimiString(source), withoutLegacyEvidence: true,
|
||||||
withoutLegacyCLI: true,
|
|
||||||
})
|
})
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install modern Kimi hooks: %v", err)
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
}
|
}
|
||||||
if len(readKimiTestHooks(t, fixture.modernPath)) != 2 {
|
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||||
t.Fatalf("modern Kimi config has unexpected hooks")
|
t.Fatalf("uninstall Kimi hooks: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(fixture.legacyPath); !os.IsNotExist(err) {
|
raw, err := os.ReadFile(fixture.modernPath)
|
||||||
t.Fatalf("false legacy migration target exists: %v", err)
|
if err != nil {
|
||||||
|
t.Fatalf("read empty inline config: %v", err)
|
||||||
}
|
}
|
||||||
status, err := fixture.plugin.Status()
|
if !strings.Contains(string(raw), "# user container") ||
|
||||||
if err != nil || !status.Installed {
|
!strings.Contains(string(raw), "# keep empty array") ||
|
||||||
t.Fatalf("empty KIMI_CODE_HOME status = %#v, %v", status, err)
|
len(readKimiTestHooks(t, fixture.modernPath)) != 0 {
|
||||||
|
t.Fatalf("empty inline array changed: %s", raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKimiLegacyInstallAvoidsPrematureModernTarget(t *testing.T) {
|
func TestKimiUsesDefaultStableAndLegacyEvidence(t *testing.T) {
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{useDefaultHome: true})
|
t.Run("stable", func(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
|
useDefaultHome: true, withoutLegacyEvidence: true,
|
||||||
|
})
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install stable Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
requireKimiManagedTriple(t, readKimiTestHooks(t, fixture.modernPath))
|
||||||
|
if _, err := os.Stat(fixture.legacyPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("legacy config exists: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("legacy", func(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
|
useDefaultHome: true, withoutModernEvidence: true,
|
||||||
|
})
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install legacy Kimi hooks: %v", err)
|
t.Fatalf("install legacy Kimi hooks: %v", err)
|
||||||
}
|
}
|
||||||
if len(readKimiTestHooks(t, fixture.legacyPath)) != 2 {
|
requireKimiManagedTriple(t, readKimiTestHooks(t, fixture.legacyPath))
|
||||||
t.Fatalf("legacy Kimi config has unexpected hooks")
|
if _, err := os.Stat(fixture.modernPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
}
|
t.Fatalf("stable config exists: %v", err)
|
||||||
if _, err := os.Stat(fixture.modernPath); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("premature modern migration target exists: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.Remove(fixture.legacyCLIPath); err != nil {
|
|
||||||
t.Fatalf("remove legacy CLI marker: %v", err)
|
|
||||||
}
|
}
|
||||||
status, err := fixture.plugin.Status()
|
status, err := fixture.plugin.Status()
|
||||||
if err != nil || !status.Installed {
|
if err != nil || !status.Installed {
|
||||||
t.Fatalf("legacy status after PATH change = %#v, %v", status, err)
|
t.Fatalf("legacy Kimi status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiParsesEverySelectedConfigBeforeRuntimeWrites(t *testing.T) {
|
||||||
|
modern := "# untouched stable\ndefault_model = \"kimi-code/k3\"\n"
|
||||||
|
legacy := "[malformed"
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
|
modernConfig: kimiString(modern), legacyConfig: kimiString(legacy),
|
||||||
|
})
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "parse kimi-cli legacy hooks config") {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
fixture.modernPath: modern, fixture.legacyPath: legacy,
|
||||||
|
} {
|
||||||
|
raw, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil || string(raw) != want {
|
||||||
|
t.Fatalf("config %s changed: %q, %v", path, raw, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNoKimiRuntimeWrites(t, fixture)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiRejectsFieldsAndEventsOutsideEachContract(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name, modern, legacy, want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"unsupported-field",
|
||||||
|
"[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ncwd=\"/tmp\"\n",
|
||||||
|
"", `unsupported field "cwd"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"legacy-stable-event", "",
|
||||||
|
"[[hooks]]\nevent=\"Interrupt\"\ncommand=\"x\"\n",
|
||||||
|
"unsupported event",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bad-timeout",
|
||||||
|
"[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ntimeout=601\n",
|
||||||
|
"", "integer from 1 to 600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hooks-object", "hooks = { event=\"PreToolUse\", command=\"x\" }\n",
|
||||||
|
"", `field "hooks" must be an array`,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
options := kimiFixtureOptions{}
|
||||||
|
if testCase.modern != "" {
|
||||||
|
options.modernConfig = kimiString(testCase.modern)
|
||||||
|
}
|
||||||
|
if testCase.legacy != "" {
|
||||||
|
options.legacyConfig = kimiString(testCase.legacy)
|
||||||
|
}
|
||||||
|
fixture := prepareKimiFixture(t, options)
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||||
|
t.Fatalf("install error = %v, want %q", err, testCase.want)
|
||||||
|
}
|
||||||
|
assertNoKimiRuntimeWrites(t, fixture)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKimiCommandsBlockAndForwardOfficialPayload(t *testing.T) {
|
func TestKimiMigratesExactLegacyCommandsAndPreservesLookalikes(t *testing.T) {
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
guard := legacyKimiCommand(t, fixture.guardPath)
|
||||||
|
audit := legacyKimiCommand(t, fixture.hookPath)
|
||||||
|
lookalike := guard + " --inspect"
|
||||||
|
source := "[[hooks]]\nevent = \"PreToolUse\"\ncommand = " + strconv.Quote(guard) +
|
||||||
|
"\ntimeout = 10\n\n[[hooks]]\nevent = \"PostToolUse\"\ncommand = " +
|
||||||
|
strconv.Quote(audit) + "\ntimeout = 10\n\n[[hooks]]\nevent = \"PreToolUse\"\ncommand = " +
|
||||||
|
strconv.Quote(lookalike) + "\ntimeout = 10\n"
|
||||||
|
writeOptionalKimiConfig(t, fixture.modernPath, kimiString(source))
|
||||||
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
t.Fatalf("migrate legacy Kimi commands: %v", err)
|
||||||
}
|
|
||||||
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
|
||||||
encodedPath, err := json.Marshal(capturePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal capture path: %v", err)
|
|
||||||
}
|
|
||||||
source := "const fs = require('node:fs'); const chunks = []; " +
|
|
||||||
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
|
|
||||||
"process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n"
|
|
||||||
if err := os.WriteFile(fixture.hookPath, []byte(source), 0700); err != nil {
|
|
||||||
t.Fatalf("write capture runtime: %v", err)
|
|
||||||
}
|
}
|
||||||
hooks := readKimiTestHooks(t, fixture.modernPath)
|
hooks := readKimiTestHooks(t, fixture.modernPath)
|
||||||
payload := map[string]any{
|
if len(hooks) != 4 {
|
||||||
"hook_event_name": "PreToolUse",
|
t.Fatalf("migrated hooks = %#v", hooks)
|
||||||
"session_id": "session-1",
|
|
||||||
"cwd": fixture.homeDir,
|
|
||||||
"tool_name": "Bash",
|
|
||||||
"tool_input": map[string]any{"command": "echo test"},
|
|
||||||
"tool_call_id": "call-1",
|
|
||||||
}
|
}
|
||||||
guard := findKimiTestHook(t, hooks, "PreToolUse", kimiGuardScript)
|
requireKimiManagedTriple(t, hooks)
|
||||||
exitCode, stderr := runKimiCommand(t, guard["command"].(string), fixture.homeDir, payload)
|
if !containsKimiCommand(hooks, lookalike) {
|
||||||
if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") {
|
t.Fatalf("lookalike command removed: %#v", hooks)
|
||||||
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
|
|
||||||
}
|
}
|
||||||
payload["hook_event_name"] = "PostToolUse"
|
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||||
payload["tool_output"] = map[string]any{"output": "test"}
|
t.Fatalf("uninstall migrated Kimi hooks: %v", err)
|
||||||
audit := findKimiTestHook(t, hooks, "PostToolUse", kimiAuditScript)
|
|
||||||
exitCode, stderr = runKimiCommand(t, audit["command"].(string), fixture.homeDir, payload)
|
|
||||||
if exitCode != 0 {
|
|
||||||
t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr)
|
|
||||||
}
|
}
|
||||||
capturedRaw, err := os.ReadFile(capturePath)
|
hooks = readKimiTestHooks(t, fixture.modernPath)
|
||||||
if err != nil {
|
if len(hooks) != 1 || hooks[0]["command"] != lookalike {
|
||||||
t.Fatalf("read captured event: %v", err)
|
t.Fatalf("remaining hooks = %#v", hooks)
|
||||||
}
|
|
||||||
var captured map[string]any
|
|
||||||
if err := json.Unmarshal(capturedRaw, &captured); err != nil {
|
|
||||||
t.Fatalf("decode captured event: %v", err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(captured, payload) {
|
|
||||||
t.Fatalf("captured event = %#v, want %#v", captured, payload)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKimiStatusAcceptsEitherContractAndRequiresBothScripts(t *testing.T) {
|
func containsKimiCommand(hooks []map[string]any, command string) bool {
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
for _, hook := range hooks {
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if hook["command"] == command {
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
return true
|
||||||
}
|
}
|
||||||
if err := os.Remove(fixture.modernPath); err != nil {
|
|
||||||
t.Fatalf("remove modern config: %v", err)
|
|
||||||
}
|
|
||||||
requireKimiInstalledStatus(t, fixture.plugin, fixture.legacyPath)
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
||||||
t.Fatalf("reinstall Kimi hooks: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.Remove(fixture.legacyPath); err != nil {
|
|
||||||
t.Fatalf("remove legacy config: %v", err)
|
|
||||||
}
|
|
||||||
requireKimiInstalledStatus(t, fixture.plugin, fixture.modernPath)
|
|
||||||
if err := os.Remove(fixture.guardPath); err != nil {
|
|
||||||
t.Fatalf("remove Kimi guard: %v", err)
|
|
||||||
}
|
|
||||||
status, err := fixture.plugin.Status()
|
|
||||||
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
|
||||||
t.Fatalf("degraded Kimi status = %#v, %v", status, err)
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireKimiInstalledStatus(t *testing.T, plugin *KimiPlugin, configPath string) {
|
func TestKimiUninstallPreservesUserConfigsAndRemovesManagedConfigs(t *testing.T) {
|
||||||
t.Helper()
|
|
||||||
status, err := plugin.Status()
|
|
||||||
if err != nil || !status.Installed || status.ConfigPath != configPath {
|
|
||||||
t.Fatalf("Kimi status = %#v, %v; want installed at %s", status, err, configPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKimiUninstallPreservesUserHooks(t *testing.T) {
|
|
||||||
userHook := "# user hook\n[[hooks]]\nevent = \"SessionStart\"\n" +
|
userHook := "# user hook\n[[hooks]]\nevent = \"SessionStart\"\n" +
|
||||||
"command = \"existing-command\"\ntimeout = 30 # keep timeout\n"
|
"command = \"existing-command\"\ntimeout = 30 # keep timeout\n"
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
modernConfig: kimiString(userHook),
|
modernConfig: kimiString(userHook), legacyConfig: kimiString(userHook),
|
||||||
legacyConfig: kimiString(userHook),
|
|
||||||
})
|
})
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
@@ -232,159 +282,129 @@ func TestKimiUninstallPreservesUserHooks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, path := range []string{fixture.modernPath, fixture.legacyPath} {
|
for _, path := range []string{fixture.modernPath, fixture.legacyPath} {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil || !strings.Contains(string(raw), "# keep timeout") {
|
||||||
t.Fatalf("read user config after uninstall: %v", err)
|
t.Fatalf("user config changed: %s, %v", raw, err)
|
||||||
}
|
|
||||||
if !strings.Contains(string(raw), "# user hook") || !strings.Contains(string(raw), "# keep timeout") {
|
|
||||||
t.Fatalf("user comments changed: %s", raw)
|
|
||||||
}
|
}
|
||||||
hooks := readKimiTestHooks(t, path)
|
hooks := readKimiTestHooks(t, path)
|
||||||
if len(hooks) != 1 || hooks[0]["command"] != "existing-command" {
|
if len(hooks) != 1 || hooks[0]["command"] != "existing-command" {
|
||||||
t.Fatalf("user hook changed: %#v", hooks)
|
t.Fatalf("user hook changed: %#v", hooks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
managed := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||||
|
if err := managed.plugin.Install(managed.config); err != nil {
|
||||||
|
t.Fatalf("install managed Kimi configs: %v", err)
|
||||||
|
}
|
||||||
|
if err := managed.plugin.Uninstall(kimiTestAgentID); err != nil {
|
||||||
|
t.Fatalf("remove managed Kimi configs: %v", err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{managed.modernPath, managed.legacyPath} {
|
||||||
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("managed config remains at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKimiOwnershipRequiresExactAgentAndScript(t *testing.T) {
|
func TestKimiStatusRequiresCompleteTripleAndEveryRuntimeFile(t *testing.T) {
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
backupPath := filepath.Join(fixture.homeDir, ".elydora", kimiTestAgentID, "hook.js.backup")
|
|
||||||
otherAgentPath := filepath.Join(fixture.homeDir, ".elydora", "agent-10", kimiAuditScript)
|
|
||||||
userHooks := "[[hooks]]\nevent = \"PostToolUse\"\ncommand = " +
|
|
||||||
strconv.Quote("node "+backupPath) + "\n\n[[hooks]]\nevent = \"PostToolUse\"\ncommand = " +
|
|
||||||
strconv.Quote("node "+otherAgentPath) + "\n"
|
|
||||||
if err := os.MkdirAll(filepath.Dir(fixture.modernPath), 0755); err != nil {
|
|
||||||
t.Fatalf("create modern Kimi directory: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(fixture.modernPath, []byte(userHooks), 0600); err != nil {
|
|
||||||
t.Fatalf("write ownership fixture: %v", err)
|
|
||||||
}
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
}
|
}
|
||||||
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
status, err := fixture.plugin.Status()
|
||||||
t.Fatalf("uninstall Kimi hooks: %v", err)
|
if err != nil || !status.Installed || !status.HookConfigured {
|
||||||
|
t.Fatalf("Kimi status = %#v, %v", status, err)
|
||||||
}
|
}
|
||||||
hooks := readKimiTestHooks(t, fixture.modernPath)
|
|
||||||
if len(hooks) != 2 {
|
|
||||||
t.Fatalf("ownership filter removed user hooks: %#v", hooks)
|
|
||||||
}
|
|
||||||
commands := []any{hooks[0]["command"], hooks[1]["command"]}
|
|
||||||
if !reflect.DeepEqual(commands, []any{"node " + backupPath, "node " + otherAgentPath}) {
|
|
||||||
t.Fatalf("remaining commands = %#v", commands)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKimiUninstallRemovesOwnedConfigs(t *testing.T) {
|
raw, err := os.ReadFile(fixture.modernPath)
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
|
||||||
}
|
|
||||||
if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil {
|
|
||||||
t.Fatalf("uninstall Kimi hooks: %v", err)
|
|
||||||
}
|
|
||||||
for _, path := range []string{fixture.modernPath, fixture.legacyPath} {
|
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("owned Kimi config remains at %s: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKimiParsesEveryConfigBeforeRuntimeWrites(t *testing.T) {
|
|
||||||
modern := "# untouched modern\ndefault_model = \"kimi-code/k3\"\n"
|
|
||||||
legacy := "[malformed"
|
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
|
||||||
modernConfig: kimiString(modern),
|
|
||||||
legacyConfig: kimiString(legacy),
|
|
||||||
})
|
|
||||||
err := fixture.plugin.Install(fixture.config)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "parse kimi-cli legacy hooks config") {
|
|
||||||
t.Fatalf("install error = %v", err)
|
|
||||||
}
|
|
||||||
for path, want := range map[string]string{fixture.modernPath: modern, fixture.legacyPath: legacy} {
|
|
||||||
raw, readErr := os.ReadFile(path)
|
|
||||||
if readErr != nil || string(raw) != want {
|
|
||||||
t.Fatalf("config %s changed: %q, %v", path, raw, readErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requireNoKimiRuntimeWrites(t, fixture)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKimiRejectsInvalidHookContractsBeforeWrites(t *testing.T) {
|
|
||||||
for _, testCase := range []struct{ name, raw, want string }{
|
|
||||||
{"unsupported-field", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ncwd=\"/tmp\"\n", `unsupported field "cwd"`},
|
|
||||||
{"unsupported-event", "[[hooks]]\nevent=\"Interrupt\"\ncommand=\"x\"\n", "unsupported event"},
|
|
||||||
{"bad-matcher", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\nmatcher=1\n", "matcher must be a string"},
|
|
||||||
{"bad-timeout", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ntimeout=601\n", "integer from 1 to 600"},
|
|
||||||
{"hooks-object", "hooks = { event=\"PreToolUse\", command=\"x\" }\n", `field "hooks" must be an array`},
|
|
||||||
} {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
|
||||||
useDefaultHome: true,
|
|
||||||
legacyConfig: kimiString(testCase.raw),
|
|
||||||
})
|
|
||||||
err := fixture.plugin.Install(fixture.config)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
|
||||||
t.Fatalf("install error = %v, want %q", err, testCase.want)
|
|
||||||
}
|
|
||||||
raw, readErr := os.ReadFile(fixture.legacyPath)
|
|
||||||
if readErr != nil || string(raw) != testCase.raw {
|
|
||||||
t.Fatalf("invalid config changed: %q, %v", raw, readErr)
|
|
||||||
}
|
|
||||||
requireNoKimiRuntimeWrites(t, fixture)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKimiRejectsMissingGuardBeforeWrites(t *testing.T) {
|
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutGuard: true})
|
|
||||||
err := fixture.plugin.Install(fixture.config)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") {
|
|
||||||
t.Fatalf("install error = %v", err)
|
|
||||||
}
|
|
||||||
for _, path := range []string{fixture.modernPath, fixture.legacyPath} {
|
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("config write occurred at %s: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requireNoKimiRuntimeWrites(t, fixture)
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireNoKimiRuntimeWrites(t *testing.T, fixture *kimiFixture) {
|
|
||||||
t.Helper()
|
|
||||||
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("runtime write occurred at %s: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKimiStatusSurfacesMalformedRuntimeMetadata(t *testing.T) {
|
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
|
||||||
t.Fatalf("corrupt runtime config: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
|
|
||||||
t.Fatalf("status error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKimiAtomicWritesLeaveNoTemporaryFiles(t *testing.T) {
|
|
||||||
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
||||||
t.Fatalf("install Kimi hooks: %v", err)
|
|
||||||
}
|
|
||||||
for _, directory := range []string{filepath.Dir(fixture.modernPath), filepath.Dir(fixture.legacyPath)} {
|
|
||||||
entries, err := os.ReadDir(directory)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read config directory: %v", err)
|
t.Fatalf("read Kimi config: %v", err)
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
document, err := parseKimiDocument(
|
||||||
if strings.HasSuffix(entry.Name(), ".tmp") {
|
stableKimiContract(fixture.modernPath), raw, true,
|
||||||
t.Fatalf("temporary Kimi config remains at %s", filepath.Join(directory, entry.Name()))
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse Kimi config: %v", err)
|
||||||
|
}
|
||||||
|
keep := make([]int, 0, len(document.hooks)-1)
|
||||||
|
for index, hook := range document.hooks {
|
||||||
|
if hook.event != "PostToolUseFailure" {
|
||||||
|
keep = append(keep, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withoutFailure, err := renderKimiHooks(document, keep, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render incomplete Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fixture.modernPath, withoutFailure, 0600); err != nil {
|
||||||
|
t.Fatalf("write incomplete Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
status, err = fixture.plugin.Status()
|
||||||
|
if err != nil || status.Installed || status.HookConfigured {
|
||||||
|
t.Fatalf("incomplete Kimi status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("repair Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey,
|
||||||
|
} {
|
||||||
|
t.Run(filepath.Base(path), func(t *testing.T) {
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read runtime file: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
t.Fatalf("remove runtime file: %v", err)
|
||||||
|
}
|
||||||
|
status, err := fixture.plugin.Status()
|
||||||
|
if err != nil || status.Installed || !status.HookConfigured {
|
||||||
|
t.Fatalf("degraded Kimi status = %#v, %v", status, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, content, 0600); err != nil {
|
||||||
|
t.Fatalf("restore runtime file: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiInstallationLeavesNoTransactionArtifacts(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
for _, path := range []string{
|
||||||
|
fixture.modernPath, fixture.legacyPath,
|
||||||
|
fixture.runtimeConfig, fixture.privateKey,
|
||||||
|
} {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect mode for %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0600 {
|
||||||
|
t.Fatalf("mode for %s = %v", path, info.Mode().Perm())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKimiRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
fixture.config.Token = ""
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kimi hooks without token: %v", err)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(fixture.runtimeConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read runtime config: %v", err)
|
||||||
|
}
|
||||||
|
var config map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &config); err != nil {
|
||||||
|
t.Fatalf("decode runtime config: %v", err)
|
||||||
|
}
|
||||||
|
if _, exists := config["token"]; exists {
|
||||||
|
t.Fatalf("empty optional token persisted: %#v", config)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,30 +15,33 @@ import (
|
|||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const kimiTestAgentID = "agent-1"
|
const (
|
||||||
|
kimiTestAgentID = "agent-1"
|
||||||
|
kimiPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"
|
||||||
|
)
|
||||||
|
|
||||||
type kimiFixtureOptions struct {
|
type kimiFixtureOptions struct {
|
||||||
modernConfig *string
|
modernConfig *string
|
||||||
legacyConfig *string
|
legacyConfig *string
|
||||||
useDefaultHome bool
|
useDefaultHome bool
|
||||||
withoutLegacyCLI bool
|
withoutModernEvidence bool
|
||||||
withoutGuard bool
|
withoutLegacyEvidence bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type kimiFixture struct {
|
type kimiFixture struct {
|
||||||
plugin *KimiPlugin
|
plugin *KimiPlugin
|
||||||
config InstallConfig
|
config InstallConfig
|
||||||
homeDir string
|
homeDir string
|
||||||
binDir string
|
projectDir string
|
||||||
kimiHome string
|
kimiHome string
|
||||||
modernPath string
|
modernPath string
|
||||||
|
legacyHome string
|
||||||
legacyPath string
|
legacyPath string
|
||||||
agentDir string
|
agentDir string
|
||||||
guardPath string
|
guardPath string
|
||||||
hookPath string
|
hookPath string
|
||||||
runtimeConfig string
|
runtimeConfig string
|
||||||
privateKey string
|
privateKey string
|
||||||
legacyCLIPath string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func kimiString(value string) *string {
|
func kimiString(value string) *string {
|
||||||
@@ -47,45 +50,38 @@ func kimiString(value string) *string {
|
|||||||
|
|
||||||
func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture {
|
func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
homeDir := filepath.Join(t.TempDir(), "home with spaces")
|
root := t.TempDir()
|
||||||
binDir := filepath.Join(homeDir, "bin")
|
homeDir := filepath.Join(root, "home with spaces and 'quote %ELYDORA_HOOK_PATH%")
|
||||||
kimiHome := filepath.Join(homeDir, "custom kimi code")
|
projectDir := filepath.Join(root, "project with spaces")
|
||||||
|
kimiHome := filepath.Join(homeDir, "custom kimi-code")
|
||||||
if options.useDefaultHome {
|
if options.useDefaultHome {
|
||||||
kimiHome = filepath.Join(homeDir, ".kimi-code")
|
kimiHome = filepath.Join(homeDir, ".kimi-code")
|
||||||
}
|
}
|
||||||
modernPath := filepath.Join(kimiHome, "config.toml")
|
modernPath := filepath.Join(kimiHome, "config.toml")
|
||||||
legacyPath := filepath.Join(homeDir, ".kimi", "config.toml")
|
legacyHome := filepath.Join(homeDir, ".kimi")
|
||||||
|
legacyPath := filepath.Join(legacyHome, "config.toml")
|
||||||
agentDir := filepath.Join(homeDir, ".elydora", kimiTestAgentID)
|
agentDir := filepath.Join(homeDir, ".elydora", kimiTestAgentID)
|
||||||
guardPath := filepath.Join(agentDir, kimiGuardScript)
|
guardPath := filepath.Join(agentDir, kimiGuardScript)
|
||||||
hookPath := filepath.Join(agentDir, kimiAuditScript)
|
hookPath := filepath.Join(agentDir, kimiAuditScript)
|
||||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
||||||
t.Fatalf("create agent directory: %v", err)
|
t.Fatalf("create project directory: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(binDir, 0755); err != nil {
|
if options.useDefaultHome && !options.withoutModernEvidence {
|
||||||
t.Fatalf("create binary directory: %v", err)
|
if err := os.MkdirAll(kimiHome, 0700); err != nil {
|
||||||
|
t.Fatalf("create Kimi Code home: %v", err)
|
||||||
}
|
}
|
||||||
if !options.withoutGuard {
|
}
|
||||||
source := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n"
|
if !options.withoutLegacyEvidence {
|
||||||
if err := os.WriteFile(guardPath, []byte(source), 0700); err != nil {
|
if err := os.MkdirAll(legacyHome, 0700); err != nil {
|
||||||
t.Fatalf("write guard runtime: %v", err)
|
t.Fatalf("create legacy Kimi home: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeOptionalKimiConfig(t, modernPath, options.modernConfig)
|
writeOptionalKimiConfig(t, modernPath, options.modernConfig)
|
||||||
writeOptionalKimiConfig(t, legacyPath, options.legacyConfig)
|
writeOptionalKimiConfig(t, legacyPath, options.legacyConfig)
|
||||||
|
|
||||||
legacyCLIPath := filepath.Join(binDir, "kimi-cli")
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
legacyCLIPath += ".cmd"
|
|
||||||
}
|
|
||||||
if !options.withoutLegacyCLI {
|
|
||||||
if err := os.WriteFile(legacyCLIPath, []byte(""), 0700); err != nil {
|
|
||||||
t.Fatalf("write legacy CLI marker: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
originalPath := os.Getenv("PATH")
|
|
||||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+originalPath)
|
|
||||||
t.Setenv("HOME", homeDir)
|
t.Setenv("HOME", homeDir)
|
||||||
t.Setenv("USERPROFILE", homeDir)
|
t.Setenv("USERPROFILE", homeDir)
|
||||||
|
t.Setenv("ELYDORA_HOOK_PATH", filepath.Join(root, "expanded-command-fragment"))
|
||||||
if options.useDefaultHome {
|
if options.useDefaultHome {
|
||||||
t.Setenv("KIMI_CODE_HOME", "")
|
t.Setenv("KIMI_CODE_HOME", "")
|
||||||
} else {
|
} else {
|
||||||
@@ -93,27 +89,17 @@ func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &kimiFixture{
|
return &kimiFixture{
|
||||||
plugin: &KimiPlugin{},
|
plugin: &KimiPlugin{}, homeDir: homeDir, projectDir: projectDir,
|
||||||
homeDir: homeDir,
|
kimiHome: kimiHome, modernPath: modernPath,
|
||||||
binDir: binDir,
|
legacyHome: legacyHome, legacyPath: legacyPath,
|
||||||
kimiHome: kimiHome,
|
agentDir: agentDir, guardPath: guardPath, hookPath: hookPath,
|
||||||
config: InstallConfig{
|
|
||||||
AgentName: kimiAgentKey,
|
|
||||||
OrgID: "org-1",
|
|
||||||
AgentID: kimiTestAgentID,
|
|
||||||
PrivateKey: "test-key",
|
|
||||||
KID: "kid-1",
|
|
||||||
BaseURL: "https://api.elydora.test",
|
|
||||||
GuardScriptPath: guardPath,
|
|
||||||
},
|
|
||||||
modernPath: modernPath,
|
|
||||||
legacyPath: legacyPath,
|
|
||||||
agentDir: agentDir,
|
|
||||||
guardPath: guardPath,
|
|
||||||
hookPath: hookPath,
|
|
||||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||||
privateKey: filepath.Join(agentDir, "private.key"),
|
privateKey: filepath.Join(agentDir, "private.key"),
|
||||||
legacyCLIPath: legacyCLIPath,
|
config: InstallConfig{
|
||||||
|
AgentName: kimiAgentKey, OrgID: "org-1", AgentID: kimiTestAgentID,
|
||||||
|
PrivateKey: kimiPrivateKey, KID: "kid-1", Token: "token-1",
|
||||||
|
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +108,7 @@ func writeOptionalKimiConfig(t *testing.T, path string, raw *string) {
|
|||||||
if raw == nil {
|
if raw == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||||
t.Fatalf("create Kimi config directory: %v", err)
|
t.Fatalf("create Kimi config directory: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(*raw), 0600); err != nil {
|
if err := os.WriteFile(path, []byte(*raw), 0600); err != nil {
|
||||||
@@ -155,36 +141,87 @@ func readKimiTestHooks(t *testing.T, path string) []map[string]any {
|
|||||||
return hooks
|
return hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
func findKimiTestHook(t *testing.T, hooks []map[string]any, event, script string) map[string]any {
|
func managedKimiTestHook(t *testing.T, hooks []map[string]any, event string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, hook := range hooks {
|
for index := len(hooks) - 1; index >= 0; index-- {
|
||||||
command, _ := hook["command"].(string)
|
hook := hooks[index]
|
||||||
if hook["event"] == event && strings.Contains(command, script) {
|
if hook["event"] == event && hook["timeout"] == int64(10) {
|
||||||
return hook
|
return hook
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Fatalf("%s hook for %s not found", event, script)
|
t.Fatalf("managed %s hook not found", event)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireStrictKimiHook(t *testing.T, hook map[string]any) {
|
func requireStrictKimiHook(t *testing.T, hook map[string]any, event string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
want := map[string]any{
|
want := map[string]any{
|
||||||
"event": hook["event"],
|
"event": event, "command": hook["command"], "timeout": int64(10),
|
||||||
"command": hook["command"],
|
|
||||||
"timeout": int64(10),
|
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(hook, want) {
|
if !reflect.DeepEqual(hook, want) {
|
||||||
t.Fatalf("hook = %#v, want strict contract %#v", hook, want)
|
t.Fatalf("hook = %#v, want strict contract %#v", hook, want)
|
||||||
}
|
}
|
||||||
|
command, ok := hook["command"].(string)
|
||||||
|
if !ok || command == "" {
|
||||||
|
t.Fatalf("Kimi command = %#v", hook["command"])
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" && !strings.Contains(command, " -EncodedCommand ") {
|
||||||
|
t.Fatalf("Windows Kimi command is not encoded PowerShell: %q", command)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runKimiCommand(t *testing.T, command, homeDir string, payload map[string]any) (int, string) {
|
func requireKimiManagedTriple(t *testing.T, hooks []map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
for _, event := range []string{"PreToolUse", "PostToolUse", "PostToolUseFailure"} {
|
||||||
|
requireStrictKimiHook(t, managedKimiTestHook(t, hooks, event), event)
|
||||||
|
}
|
||||||
|
if managedKimiTestHook(t, hooks, "PostToolUse")["command"] !=
|
||||||
|
managedKimiTestHook(t, hooks, "PostToolUseFailure")["command"] {
|
||||||
|
t.Fatal("Kimi success and failure audit commands differ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kimiOfficialPayload(event string) map[string]any {
|
||||||
|
payload := map[string]any{
|
||||||
|
"hook_event_name": event,
|
||||||
|
"session_id": "session-1",
|
||||||
|
"cwd": "C:/project",
|
||||||
|
"tool_name": "Bash",
|
||||||
|
"tool_input": map[string]any{"command": "echo test"},
|
||||||
|
"tool_call_id": "call-1",
|
||||||
|
}
|
||||||
|
switch event {
|
||||||
|
case "PostToolUse":
|
||||||
|
payload["tool_output"] = map[string]any{"output": "test", "success": true}
|
||||||
|
case "PostToolUseFailure":
|
||||||
|
payload["error"] = map[string]any{
|
||||||
|
"name": "ToolError", "message": "command failed", "code": "tool.failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func runKimiCommand(
|
||||||
|
t *testing.T,
|
||||||
|
command string,
|
||||||
|
fixture *kimiFixture,
|
||||||
|
payload map[string]any,
|
||||||
|
) (int, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
encoded, err := json.Marshal(payload)
|
encoded, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal Kimi payload: %v", err)
|
t.Fatalf("marshal Kimi payload: %v", err)
|
||||||
}
|
}
|
||||||
|
return runKimiRawCommand(t, command, fixture, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runKimiRawCommand(
|
||||||
|
t *testing.T,
|
||||||
|
command string,
|
||||||
|
fixture *kimiFixture,
|
||||||
|
encoded []byte,
|
||||||
|
) (int, string) {
|
||||||
|
t.Helper()
|
||||||
var process *exec.Cmd
|
var process *exec.Cmd
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
commandFile := filepath.Join(t.TempDir(), "run-kimi-hook.cmd")
|
commandFile := filepath.Join(t.TempDir(), "run-kimi-hook.cmd")
|
||||||
@@ -195,11 +232,17 @@ func runKimiCommand(t *testing.T, command, homeDir string, payload map[string]an
|
|||||||
} else {
|
} else {
|
||||||
process = exec.Command("sh", "-c", command)
|
process = exec.Command("sh", "-c", command)
|
||||||
}
|
}
|
||||||
process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
|
process.Dir = fixture.projectDir
|
||||||
|
process.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"HOME="+fixture.homeDir,
|
||||||
|
"USERPROFILE="+fixture.homeDir,
|
||||||
|
"ELYDORA_HOOK_PATH=injected-command-fragment",
|
||||||
|
)
|
||||||
process.Stdin = bytes.NewReader(encoded)
|
process.Stdin = bytes.NewReader(encoded)
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
process.Stderr = &stderr
|
process.Stderr = &stderr
|
||||||
err = process.Run()
|
err := process.Run()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return 0, stderr.String()
|
return 0, stderr.String()
|
||||||
}
|
}
|
||||||
@@ -210,3 +253,53 @@ func runKimiCommand(t *testing.T, command, homeDir string, payload map[string]an
|
|||||||
t.Fatalf("run Kimi hook command: %v", err)
|
t.Fatalf("run Kimi hook command: %v", err)
|
||||||
return -1, stderr.String()
|
return -1, stderr.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func legacyKimiCommand(t *testing.T, scriptPath string) string {
|
||||||
|
t.Helper()
|
||||||
|
nodePath, err := resolveNodeRuntime()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve Node.js runtime: %v", err)
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return quoteWindowsArgument(nodePath) + " " + quoteWindowsArgument(scriptPath)
|
||||||
|
}
|
||||||
|
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoKimiRuntimeWrites(t *testing.T, fixture *kimiFixture) {
|
||||||
|
t.Helper()
|
||||||
|
for _, path := range []string{
|
||||||
|
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath,
|
||||||
|
} {
|
||||||
|
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("runtime file exists at %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoKimiTransactionArtifacts(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(entry.Name(), ".tmp") ||
|
||||||
|
strings.HasSuffix(entry.Name(), ".rollback") {
|
||||||
|
t.Errorf("transaction artifact remains at %s", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walk Kimi fixture: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kimiSymlinkOrSkip(t *testing.T, target, link string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Skipf("symbolic links unavailable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ type kimiTomlLayout struct {
|
|||||||
|
|
||||||
type kimiDocument struct {
|
type kimiDocument struct {
|
||||||
contract kimiContract
|
contract kimiContract
|
||||||
|
exists bool
|
||||||
raw []byte
|
raw []byte
|
||||||
hooks []kimiHook
|
hooks []kimiHook
|
||||||
layout kimiTomlLayout
|
layout kimiTomlLayout
|
||||||
@@ -31,7 +32,9 @@ type kimiDocument struct {
|
|||||||
|
|
||||||
func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocument, error) {
|
func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocument, error) {
|
||||||
if !exists {
|
if !exists {
|
||||||
return kimiDocument{contract: contract, raw: []byte{}, hooks: []kimiHook{}}, nil
|
return kimiDocument{
|
||||||
|
contract: contract, exists: false, raw: []byte{}, hooks: []kimiHook{},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
root := map[string]any{}
|
root := map[string]any{}
|
||||||
if err := toml.Unmarshal(raw, &root); err != nil {
|
if err := toml.Unmarshal(raw, &root); err != nil {
|
||||||
@@ -76,6 +79,7 @@ func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocu
|
|||||||
}
|
}
|
||||||
return kimiDocument{
|
return kimiDocument{
|
||||||
contract: contract,
|
contract: contract,
|
||||||
|
exists: true,
|
||||||
raw: append([]byte(nil), raw...),
|
raw: append([]byte(nil), raw...),
|
||||||
hooks: hooks,
|
hooks: hooks,
|
||||||
layout: layout,
|
layout: layout,
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func snapshotKimiFiles(t *testing.T, paths ...string) map[string][]byte {
|
||||||
|
t.Helper()
|
||||||
|
result := make(map[string][]byte, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read snapshot %s: %v", path, err)
|
||||||
|
}
|
||||||
|
result[path] = raw
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireKimiSnapshot(t *testing.T, snapshot map[string][]byte) {
|
||||||
|
t.Helper()
|
||||||
|
for path, want := range snapshot {
|
||||||
|
actual, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(actual) != string(want) {
|
||||||
|
t.Fatalf("snapshot changed at %s: %q, %v", path, actual, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiInstallRollsBackRuntimeAndBothConfigs(t *testing.T) {
|
||||||
|
stable := []byte("# stable owner config\ndefault_model = \"kimi-code/k3\"\n")
|
||||||
|
legacy := []byte("# legacy owner config\ntelemetry = false\n")
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
|
modernConfig: kimiString(string(stable)), legacyConfig: kimiString(string(legacy)),
|
||||||
|
})
|
||||||
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
|
if sameKimiPath(destination, fixture.legacyPath) {
|
||||||
|
return errors.New("injected legacy config failure")
|
||||||
|
}
|
||||||
|
return os.Rename(source, destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "injected legacy config failure") {
|
||||||
|
t.Fatalf("install error = %v", err)
|
||||||
|
}
|
||||||
|
for path, want := range map[string][]byte{
|
||||||
|
fixture.modernPath: stable, fixture.legacyPath: legacy,
|
||||||
|
} {
|
||||||
|
actual, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil || string(actual) != string(want) {
|
||||||
|
t.Fatalf("config changed at %s: %q, %v", path, actual, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNoKimiRuntimeWrites(t, fixture)
|
||||||
|
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiInstallPreservesConcurrentConfigMutation(t *testing.T) {
|
||||||
|
original := "# original owner config\ndefault_model = \"kimi-code/k3\"\n"
|
||||||
|
concurrent := []byte("# concurrent owner change\ntelemetry = false\n")
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
|
modernConfig: kimiString(original), withoutLegacyEvidence: true,
|
||||||
|
})
|
||||||
|
mutated := false
|
||||||
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
|
if !mutated && strings.HasSuffix(source, ".tmp") {
|
||||||
|
mutated = true
|
||||||
|
if err := os.WriteFile(fixture.modernPath, concurrent, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.Rename(source, destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||||
|
t.Fatalf("concurrent install error = %v", err)
|
||||||
|
}
|
||||||
|
actual, readErr := os.ReadFile(fixture.modernPath)
|
||||||
|
if readErr != nil || string(actual) != string(concurrent) {
|
||||||
|
t.Fatalf("concurrent config changed: %q, %v", actual, readErr)
|
||||||
|
}
|
||||||
|
assertNoKimiRuntimeWrites(t, fixture)
|
||||||
|
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiInstallDetectsConcurrentConfigIdentityReplacement(t *testing.T) {
|
||||||
|
original := []byte("# original owner config\n")
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{
|
||||||
|
modernConfig: kimiString(string(original)), withoutLegacyEvidence: true,
|
||||||
|
})
|
||||||
|
mutated := false
|
||||||
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
|
if !mutated && strings.HasSuffix(source, ".tmp") {
|
||||||
|
mutated = true
|
||||||
|
external := fixture.modernPath + ".external"
|
||||||
|
if err := os.Rename(fixture.modernPath, external); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fixture.modernPath, original, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.Rename(source, destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := fixture.plugin.Install(fixture.config)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||||
|
t.Fatalf("concurrent identity error = %v", err)
|
||||||
|
}
|
||||||
|
actual, readErr := os.ReadFile(fixture.modernPath)
|
||||||
|
if readErr != nil || string(actual) != string(original) {
|
||||||
|
t.Fatalf("replacement config changed: %q, %v", actual, readErr)
|
||||||
|
}
|
||||||
|
assertNoKimiRuntimeWrites(t, fixture)
|
||||||
|
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKimiUninstallRollsBackBothConfigRemovals(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{})
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Kimi hooks: %v", err)
|
||||||
|
}
|
||||||
|
paths := []string{
|
||||||
|
fixture.guardPath, fixture.runtimeConfig, fixture.privateKey, fixture.hookPath,
|
||||||
|
fixture.modernPath, fixture.legacyPath,
|
||||||
|
}
|
||||||
|
before := snapshotKimiFiles(t, paths...)
|
||||||
|
removals := 0
|
||||||
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
|
if strings.HasSuffix(destination, ".rollback") {
|
||||||
|
removals++
|
||||||
|
if removals == 2 {
|
||||||
|
return errors.New("injected uninstall failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.Rename(source, destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := fixture.plugin.Uninstall(kimiTestAgentID)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "injected uninstall failure") {
|
||||||
|
t.Fatalf("uninstall error = %v", err)
|
||||||
|
}
|
||||||
|
requireKimiSnapshot(t, before)
|
||||||
|
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) {
|
||||||
|
fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true})
|
||||||
|
documents, err := readAllKimiConfigs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read Kimi configs: %v", err)
|
||||||
|
}
|
||||||
|
paths, nodePath, err := preflightKimiInstallation(fixture.config, documents)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("preflight Kimi installation: %v", err)
|
||||||
|
}
|
||||||
|
guardCommand, err := buildKimiCommand(nodePath, paths.guardPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build guard command: %v", err)
|
||||||
|
}
|
||||||
|
auditCommand, err := buildKimiCommand(nodePath, paths.auditPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build audit command: %v", err)
|
||||||
|
}
|
||||||
|
additions := make([]kimiHook, 0, 3)
|
||||||
|
for _, item := range []struct{ event, command string }{
|
||||||
|
{"PreToolUse", guardCommand},
|
||||||
|
{"PostToolUse", auditCommand},
|
||||||
|
{"PostToolUseFailure", auditCommand},
|
||||||
|
} {
|
||||||
|
hook, hookErr := buildKimiHook(item.event, item.command)
|
||||||
|
if hookErr != nil {
|
||||||
|
t.Fatalf("build Kimi hook: %v", hookErr)
|
||||||
|
}
|
||||||
|
additions = append(additions, hook)
|
||||||
|
}
|
||||||
|
rendered := make([]kimiRenderedDocument, 0, len(documents))
|
||||||
|
for _, document := range documents {
|
||||||
|
keep, keepErr := keptKimiHookIndices(document.hooks, "")
|
||||||
|
if keepErr != nil {
|
||||||
|
t.Fatalf("select Kimi hooks: %v", keepErr)
|
||||||
|
}
|
||||||
|
change, renderErr := renderKimiChange(document, keep, additions)
|
||||||
|
if renderErr != nil {
|
||||||
|
t.Fatalf("render Kimi hooks: %v", renderErr)
|
||||||
|
}
|
||||||
|
rendered = append(rendered, change)
|
||||||
|
}
|
||||||
|
changes, err := prepareKimiInstallationChanges(fixture.config, paths, rendered)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare Kimi installation: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fixture.modernPath), 0700); err != nil {
|
||||||
|
t.Fatalf("create Kimi home: %v", err)
|
||||||
|
}
|
||||||
|
concurrent := []byte("# changed after preparation\n")
|
||||||
|
if err := os.WriteFile(fixture.modernPath, concurrent, 0600); err != nil {
|
||||||
|
t.Fatalf("write concurrent source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeKimiChanges(
|
||||||
|
changes,
|
||||||
|
"Install Kimi hooks",
|
||||||
|
nil,
|
||||||
|
paths.runtimeRoot,
|
||||||
|
paths.agentDirectory,
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||||
|
t.Fatalf("prepared install error = %v", err)
|
||||||
|
}
|
||||||
|
actual, readErr := os.ReadFile(fixture.modernPath)
|
||||||
|
if readErr != nil || string(actual) != string(concurrent) {
|
||||||
|
t.Fatalf("prepared source changed: %q, %v", actual, readErr)
|
||||||
|
}
|
||||||
|
assertNoKimiRuntimeWrites(t, fixture)
|
||||||
|
assertNoKimiTransactionArtifacts(t, fixture.homeDir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateManagedPrivateKey(value string) error {
|
||||||
|
seed, err := base64.RawURLEncoding.DecodeString(value)
|
||||||
|
if err != nil || len(seed) != 32 || base64.RawURLEncoding.EncodeToString(seed) != value {
|
||||||
|
return fmt.Errorf("private key must be a canonical 32-byte base64url value")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManagedBaseURL(value string) error {
|
||||||
|
if strings.ContainsRune(value, '\\') {
|
||||||
|
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if character < 32 || unicode.IsSpace(character) {
|
||||||
|
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||||
|
}
|
||||||
|
validScheme := strings.EqualFold(parsed.Scheme, "http") ||
|
||||||
|
strings.EqualFold(parsed.Scheme, "https")
|
||||||
|
if !validScheme || parsed.Host == "" || parsed.Hostname() == "" || parsed.Opaque != "" {
|
||||||
|
return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL")
|
||||||
|
}
|
||||||
|
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"base URL must exclude credentials, query parameters, and fragments",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
port := parsed.Port()
|
||||||
|
if strings.HasSuffix(parsed.Host, ":") {
|
||||||
|
return fmt.Errorf("base URL must contain a valid port")
|
||||||
|
}
|
||||||
|
if port != "" {
|
||||||
|
value, convertErr := strconv.Atoi(port)
|
||||||
|
if convertErr != nil || value < 1 || value > 65535 {
|
||||||
|
return fmt.Errorf("base URL must contain a valid port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user