feat: add native Cline hook integration

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 10:19:29 -04:00
1 parent 9d03a68477
commit 2eff1ae119
10 files changed
+1316 -5

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
package plugins
import "fmt"
// ClinePlugin manages Cline's native global file hooks.
type ClinePlugin struct{}
func (p *ClinePlugin) Install(config InstallConfig) error {
if config.AgentID == "" {
return fmt.Errorf("agent ID is required")
}
paths, err := resolveClineHookFiles()
if err != nil {
return err
}
guardState, err := readClineHookFile(paths.guardPath)
if err != nil {
return err
}
auditState, err := readClineHookFile(paths.auditPath)
if err != nil {
return err
}
if err := requireAvailableClineHook(guardState); err != nil {
return err
}
if err := requireAvailableClineHook(auditState); err != nil {
return err
}
if config.GuardScriptPath == "" {
return fmt.Errorf("guard script path is required")
}
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
if err != nil {
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
}
guardMetadata, err := buildClineMetadata("guard", config.AgentID, config.GuardScriptPath)
if err != nil {
return fmt.Errorf("build Cline guard metadata: %w", err)
}
auditMetadata, err := buildClineMetadata("audit", config.AgentID, hookPath)
if err != nil {
return fmt.Errorf("build Cline audit metadata: %w", err)
}
guardSource, err := buildClineWrapper(guardMetadata)
if err != nil {
return fmt.Errorf("build Cline guard wrapper: %w", err)
}
auditSource, err := buildClineWrapper(auditMetadata)
if err != nil {
return fmt.Errorf("build Cline audit wrapper: %w", err)
}
if _, err := clineContractForFiles(
clineHookFile{
exists: true, filePath: paths.guardPath,
source: guardSource, metadata: &guardMetadata,
},
clineHookFile{
exists: true, filePath: paths.auditPath,
source: auditSource, metadata: &auditMetadata,
},
); err != nil {
return err
}
runtimeConfig := config
runtimeConfig.AgentName = clineAgentKey
if err := GenerateHookScript(hookPath, runtimeConfig); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
if err := writeClineHookPair(
clinePendingWrite{state: guardState, source: guardSource},
clinePendingWrite{state: auditState, source: auditSource},
); err != nil {
return err
}
fmt.Println("Cline: user-level PreToolUse and PostToolUse hooks installed.")
return nil
}
func (p *ClinePlugin) Uninstall(agentID string) error {
paths, err := resolveClineHookFiles()
if err != nil {
return err
}
guardState, err := readClineHookFile(paths.guardPath)
if err != nil {
return err
}
auditState, err := readClineHookFile(paths.auditPath)
if err != nil {
return err
}
return removeOwnedClineHooks([]clineHookFile{guardState, auditState}, agentID)
}
func (p *ClinePlugin) Status() (PluginStatus, error) {
paths, err := resolveClineHookFiles()
status := PluginStatus{
AgentName: clineAgentKey,
DisplayName: "Cline",
ConfigPath: paths.hooksDirectory,
}
if err != nil {
return status, err
}
guardState, err := readClineHookFile(paths.guardPath)
if err != nil {
return status, err
}
auditState, err := readClineHookFile(paths.auditPath)
if err != nil {
return status, err
}
contract, err := clineContractForFiles(guardState, auditState)
if err != nil || contract == nil {
return status, err
}
status.HookConfigured = true
status.HookScriptExists, err = clineRuntimeFilesExist(contract)
if err != nil {
return status, err
}
status.Installed = status.HookScriptExists
return status, nil
}
+360
View File
@@ -0,0 +1,360 @@
package plugins
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
const (
clineAgentKey = "cline"
clineGuardFileName = "PreToolUse.mjs"
clineAuditFileName = "PostToolUse.mjs"
clineGuardScript = "guard.js"
clineAuditScript = "hook.js"
clineMetadataMarker = "// @elydora-cline-hook "
clineMetadataVersion = 1
)
type clineHookMetadata struct {
Version int `json:"version"`
Kind string `json:"kind"`
AgentID string `json:"agentId"`
RuntimePath string `json:"runtimePath"`
}
type clineHookFile struct {
exists bool
filePath string
source string
metadata *clineHookMetadata
}
type clineHookPaths struct {
hooksDirectory string
guardPath string
auditPath string
}
type clineRuntimeContract struct {
agentID string
agentDirectory string
guardPath string
auditPath string
}
func clineHomeDirectory() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return home, nil
}
func clineElydoraRoot() (string, error) {
home, err := clineHomeDirectory()
if err != nil {
return "", err
}
return filepath.Join(home, ".elydora"), nil
}
func resolveClineHookFiles() (clineHookPaths, error) {
clineDirectory := strings.TrimSpace(os.Getenv("CLINE_DIR"))
if clineDirectory == "" {
home, err := clineHomeDirectory()
if err != nil {
return clineHookPaths{}, err
}
clineDirectory = filepath.Join(home, ".cline")
} else {
absolute, err := filepath.Abs(clineDirectory)
if err != nil {
return clineHookPaths{}, fmt.Errorf("resolve CLINE_DIR path: %w", err)
}
clineDirectory = absolute
}
hooksDirectory := filepath.Join(clineDirectory, "hooks")
return clineHookPaths{
hooksDirectory: hooksDirectory,
guardPath: filepath.Join(hooksDirectory, clineGuardFileName),
auditPath: filepath.Join(hooksDirectory, clineAuditFileName),
}, nil
}
func sameClineAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func normalizeClinePath(value string) (string, error) {
absolute, err := filepath.Abs(value)
if err != nil {
return "", err
}
normalized := filepath.Clean(absolute)
if runtime.GOOS == "windows" {
normalized = strings.ToLower(normalized)
}
return normalized, nil
}
func sameClinePath(left, right string) (bool, error) {
normalizedLeft, err := normalizeClinePath(left)
if err != nil {
return false, err
}
normalizedRight, err := normalizeClinePath(right)
if err != nil {
return false, err
}
return normalizedLeft == normalizedRight, nil
}
func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) {
metadata := clineHookMetadata{
Version: clineMetadataVersion,
Kind: kind,
AgentID: agentID,
RuntimePath: runtimePath,
}
if err := validateClineMetadata(metadata); err != nil {
return clineHookMetadata{}, err
}
return metadata, nil
}
func validateClineMetadata(metadata clineHookMetadata) error {
if metadata.Version != clineMetadataVersion {
return fmt.Errorf("metadata version must be 1")
}
if metadata.Kind != "guard" && metadata.Kind != "audit" {
return fmt.Errorf(`metadata kind must be "guard" or "audit"`)
}
if metadata.AgentID == "" {
return fmt.Errorf("metadata agentId must be a non-empty string")
}
if !filepath.IsAbs(metadata.RuntimePath) {
return fmt.Errorf("metadata runtimePath must be an absolute path")
}
return nil
}
func encodeClineMetadata(metadata clineHookMetadata) (string, error) {
encoded, err := json.Marshal(metadata)
if err != nil {
return "", fmt.Errorf("encode Cline hook metadata: %w", err)
}
return base64.RawURLEncoding.EncodeToString(encoded), nil
}
func clineJSONString(value string) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(encoded), nil
}
func buildClineWrapper(metadata clineHookMetadata) (string, error) {
if err := validateClineMetadata(metadata); err != nil {
return "", err
}
encodedMetadata, err := encodeClineMetadata(metadata)
if err != nil {
return "", err
}
hookKind, err := clineJSONString(metadata.Kind)
if err != nil {
return "", fmt.Errorf("encode Cline hook kind: %w", err)
}
runtimePath, err := clineJSONString(metadata.RuntimePath)
if err != nil {
return "", fmt.Errorf("encode Cline runtime path: %w", err)
}
const template = `#!/usr/bin/env node
__METADATA_LINE__
import { spawn } from 'node:child_process';
const hookKind = __HOOK_KIND__;
const runtimePath = __RUNTIME_PATH__;
function runRuntime(input) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [runtimePath], {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
const stdout = [];
const stderr = [];
let stdinError;
child.stdout.on('data', (chunk) => stdout.push(chunk));
child.stderr.on('data', (chunk) => stderr.push(chunk));
child.stdin.on('error', (error) => {
if (error?.code !== 'EPIPE') stdinError = error;
});
child.once('error', reject);
child.once('close', (code, signal) => {
if (stdinError) {
reject(stdinError);
return;
}
resolve({
code,
signal,
stdout: Buffer.concat(stdout).toString('utf-8'),
stderr: Buffer.concat(stderr).toString('utf-8'),
});
});
child.stdin.end(input);
});
}
async function main() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const result = await runRuntime(Buffer.concat(chunks));
if (result.stdout) process.stderr.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
if (hookKind === 'guard' && result.code === 2) {
const errorMessage = result.stderr.trim() || 'Agent is frozen by Elydora.';
process.stdout.write('HOOK_CONTROL\t' + JSON.stringify({ cancel: true, errorMessage }) + '\n');
return;
}
if (result.signal) throw new Error('runtime terminated by signal ' + result.signal);
if (result.code !== 0) throw new Error('runtime exited with code ' + result.code);
}
main().catch((error) => {
const message = error instanceof Error ? error.stack || error.message : String(error);
process.stderr.write('Elydora Cline ' + hookKind + ' wrapper failed: ' + message + '\n');
process.exitCode = 1;
});
`
return strings.NewReplacer(
"__METADATA_LINE__", clineMetadataMarker+encodedMetadata,
"__HOOK_KIND__", hookKind,
"__RUNTIME_PATH__", runtimePath,
).Replace(template), nil
}
func decodeClineMetadata(encoded string) (clineHookMetadata, error) {
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err)
}
expected := []string{"version", "kind", "agentId", "runtimePath"}
if len(fields) != len(expected) {
return clineHookMetadata{}, fmt.Errorf("metadata contains an unexpected field set")
}
for _, field := range expected {
if _, exists := fields[field]; !exists {
return clineHookMetadata{}, fmt.Errorf("metadata contains an unexpected field set")
}
}
var metadata clineHookMetadata
if err := json.Unmarshal(raw, &metadata); err != nil {
return clineHookMetadata{}, fmt.Errorf("invalid encoded metadata: %w", err)
}
if err := validateClineMetadata(metadata); err != nil {
return clineHookMetadata{}, err
}
return metadata, nil
}
func parseClineMetadata(filePath, source string) (*clineHookMetadata, error) {
lines := strings.SplitN(source, "\n", 3)
if len(lines) < 2 {
return nil, nil
}
markerLine := strings.TrimSuffix(lines[1], "\r")
if !strings.HasPrefix(markerLine, clineMetadataMarker) {
return nil, nil
}
metadata, err := decodeClineMetadata(strings.TrimPrefix(markerLine, clineMetadataMarker))
if err != nil {
return nil, fmt.Errorf("parse Elydora Cline hook metadata at %s: %w", filePath, err)
}
return &metadata, nil
}
func assertClineWrapperIntegrity(file clineHookFile) error {
if file.metadata == nil {
return nil
}
expected, err := buildClineWrapper(*file.metadata)
if err != nil {
return err
}
if file.source != expected {
return fmt.Errorf("Elydora Cline hook at %s does not match the managed template", file.filePath)
}
return nil
}
func validateClineAgentSegment(agentID string) error {
if agentID == "." || agentID == ".." || filepath.Base(agentID) != agentID {
return fmt.Errorf("Elydora Cline hook metadata contains an invalid agentId")
}
return nil
}
func clineContractForFiles(
guardFile, auditFile clineHookFile,
) (*clineRuntimeContract, error) {
if guardFile.metadata == nil || auditFile.metadata == nil {
return nil, nil
}
if err := assertClineWrapperIntegrity(guardFile); err != nil {
return nil, err
}
if err := assertClineWrapperIntegrity(auditFile); err != nil {
return nil, err
}
guard := guardFile.metadata
audit := auditFile.metadata
if guard.Kind != "guard" || audit.Kind != "audit" {
return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata")
}
if !sameClineAgentID(guard.AgentID, audit.AgentID) {
return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
}
if err := validateClineAgentSegment(guard.AgentID); err != nil {
return nil, err
}
runtimeRoot, err := clineElydoraRoot()
if err != nil {
return nil, err
}
agentDirectory := filepath.Join(runtimeRoot, guard.AgentID)
expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard)
if err != nil {
return nil, fmt.Errorf("compare Cline guard runtime path: %w", err)
}
auditMatches, err := sameClinePath(audit.RuntimePath, expectedAudit)
if err != nil {
return nil, fmt.Errorf("compare Cline audit runtime path: %w", err)
}
if !guardMatches || !auditMatches {
return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path")
}
return &clineRuntimeContract{
agentID: guard.AgentID,
agentDirectory: agentDirectory,
guardPath: expectedGuard,
auditPath: expectedAudit,
}, nil
}
+211
View File
@@ -0,0 +1,211 @@
package plugins
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
type clinePendingWrite struct {
state clineHookFile
source string
}
type clineStagedFile struct {
state clineHookFile
temporaryPath string
}
type clineRenameFunc func(source, destination string) error
func readClineHookFile(filePath string) (clineHookFile, error) {
// #nosec G304 -- filePath is resolved from Cline's native hook directory.
raw, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return clineHookFile{filePath: filePath}, nil
}
return clineHookFile{}, fmt.Errorf("read Cline hook at %s: %w", filePath, err)
}
source := string(raw)
metadata, err := parseClineMetadata(filePath, source)
if err != nil {
return clineHookFile{}, err
}
return clineHookFile{
exists: true,
filePath: filePath,
source: source,
metadata: metadata,
}, nil
}
func requireAvailableClineHook(file clineHookFile) error {
if file.exists && file.metadata == nil {
return fmt.Errorf(
"Cline hook at %s already exists and is owned by another integration",
file.filePath,
)
}
return nil
}
func removeClineTemporary(path string) error {
if path == "" {
return nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove temporary Cline hook at %s: %w", path, err)
}
return nil
}
func failClineStage(temporary *os.File, path string, cause error) error {
closeErr := temporary.Close()
removeErr := removeClineTemporary(path)
return errors.Join(cause, closeErr, removeErr)
}
func stageClineHook(write clinePendingWrite) (clineStagedFile, error) {
directory := filepath.Dir(write.state.filePath)
if err := os.MkdirAll(directory, 0700); err != nil {
return clineStagedFile{}, fmt.Errorf("create Cline hooks directory at %s: %w", directory, err)
}
temporary, err := os.CreateTemp(directory, "."+filepath.Base(write.state.filePath)+".*.tmp")
if err != nil {
return clineStagedFile{}, fmt.Errorf("stage Cline hook at %s: %w", write.state.filePath, err)
}
temporaryPath := temporary.Name()
if err := temporary.Chmod(0700); err != nil {
return clineStagedFile{}, failClineStage(
temporary,
temporaryPath,
fmt.Errorf("set permissions for %s: %w", temporaryPath, err),
)
}
written, err := temporary.WriteString(write.source)
if err != nil {
return clineStagedFile{}, failClineStage(
temporary,
temporaryPath,
fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, err),
)
}
if written != len(write.source) {
return clineStagedFile{}, failClineStage(
temporary,
temporaryPath,
fmt.Errorf("write temporary Cline hook for %s: %w", write.state.filePath, io.ErrShortWrite),
)
}
if err := temporary.Sync(); err != nil {
return clineStagedFile{}, failClineStage(
temporary,
temporaryPath,
fmt.Errorf("sync temporary Cline hook for %s: %w", write.state.filePath, err),
)
}
if err := temporary.Close(); err != nil {
return clineStagedFile{}, errors.Join(
fmt.Errorf("close temporary Cline hook for %s: %w", write.state.filePath, err),
removeClineTemporary(temporaryPath),
)
}
return clineStagedFile{state: write.state, temporaryPath: temporaryPath}, nil
}
func rollbackClineHook(state clineHookFile) error {
if state.exists {
return writeHookFileAtomic(state.filePath, []byte(state.source), 0700)
}
return removeHookFile(state.filePath, "Cline hook rollback")
}
func writeClineHookPair(guard, audit clinePendingWrite) error {
return writeClineHookPairWithRename(guard, audit, os.Rename)
}
func writeClineHookPairWithRename(
guard, audit clinePendingWrite,
rename clineRenameFunc,
) error {
writes := []clinePendingWrite{guard, audit}
staged := make([]clineStagedFile, 0, len(writes))
for _, write := range writes {
file, err := stageClineHook(write)
if err != nil {
failures := []error{fmt.Errorf("stage Cline hook pair: %w", err)}
for _, item := range staged {
if cleanupErr := removeClineTemporary(item.temporaryPath); cleanupErr != nil {
failures = append(failures, cleanupErr)
}
}
return errors.Join(failures...)
}
staged = append(staged, file)
}
committed := 0
for index, item := range staged {
if err := rename(item.temporaryPath, item.state.filePath); err != nil {
failures := []error{
fmt.Errorf("write Cline hook pair at %s: %w", item.state.filePath, err),
}
for rollbackIndex := committed - 1; rollbackIndex >= 0; rollbackIndex-- {
if rollbackErr := rollbackClineHook(staged[rollbackIndex].state); rollbackErr != nil {
failures = append(failures, fmt.Errorf(
"restore Cline hook at %s: %w",
staged[rollbackIndex].state.filePath,
rollbackErr,
))
}
}
for cleanupIndex := index; cleanupIndex < len(staged); cleanupIndex++ {
if cleanupErr := removeClineTemporary(staged[cleanupIndex].temporaryPath); cleanupErr != nil {
failures = append(failures, cleanupErr)
}
}
return errors.Join(failures...)
}
committed++
}
return nil
}
func removeOwnedClineHooks(files []clineHookFile, agentID string) error {
for _, file := range files {
if file.metadata == nil {
continue
}
if agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID) {
continue
}
if err := removeHookFile(file.filePath, "Cline hook"); err != nil {
return err
}
}
return nil
}
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
configPath := filepath.Join(contract.agentDirectory, "config.json")
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
if err != nil || !exists {
return false, err
}
agentID, ok := config["agent_id"].(string)
if config["agent_name"] != clineAgentKey || !ok || !sameClineAgentID(agentID, contract.agentID) {
return false, nil
}
guardExists, err := regularFileExists(contract.guardPath, "Elydora guard runtime")
if err != nil {
return false, err
}
auditExists, err := regularFileExists(contract.auditPath, "Elydora audit runtime")
if err != nil {
return false, err
}
return guardExists && auditExists, nil
}
+206
View File
@@ -0,0 +1,206 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestClineStatusRequiresIntactHooksAndRuntimes(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
status, err := fixture.plugin.Status()
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
t.Fatalf("installed status = %#v, %v", status, err)
}
if status.AgentName != "cline" || status.DisplayName != "Cline" || status.ConfigPath != fixture.hooksDir {
t.Fatalf("status metadata = %#v", status)
}
if err := os.Remove(fixture.guardPath); err != nil {
t.Fatalf("remove guard runtime: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
t.Fatalf("degraded status = %#v, %v", status, err)
}
if err := os.WriteFile(fixture.guardPath, []byte("process.exit(0);\n"), 0700); err != nil {
t.Fatalf("restore guard runtime: %v", err)
}
if err := os.Remove(fixture.auditWrapper); err != nil {
t.Fatalf("remove audit wrapper: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || status.Installed || status.HookConfigured {
t.Fatalf("partial status = %#v, %v", status, err)
}
}
func TestClineStatusSurfacesCorruptHooksAndRuntimeMetadata(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
guard := readClineTestFile(t, fixture.guardWrapper)
if err := os.WriteFile(fixture.guardWrapper, []byte(guard+"\n// tampered\n"), 0700); err != nil {
t.Fatalf("tamper guard wrapper: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "managed template") {
t.Fatalf("tampered status error = %v", err)
}
installClineFixture(t, fixture)
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("runtime status error = %v", err)
}
}
func TestClineInstallRejectsUserFilenameCollisionsBeforeWrites(t *testing.T) {
for _, collision := range []string{"guard", "audit"} {
t.Run(collision, func(t *testing.T) {
options := clineFixtureOptions{}
if collision == "guard" {
options.ExistingGuard = clineString("// user PreToolUse hook\n")
} else {
options.ExistingAudit = clineString("// user PostToolUse hook\n")
}
fixture := prepareClineFixture(t, options)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "owned by another integration") {
t.Fatalf("install error = %v", err)
}
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
requireMissingClineTestFile(t, path)
}
if options.ExistingGuard != nil && readClineTestFile(t, fixture.guardWrapper) != *options.ExistingGuard {
t.Fatalf("user guard wrapper changed")
}
if options.ExistingAudit != nil && readClineTestFile(t, fixture.auditWrapper) != *options.ExistingAudit {
t.Fatalf("user audit wrapper changed")
}
})
}
}
func TestClineInstallPreservesCorruptOwnedMetadataForRecovery(t *testing.T) {
corrupt := "#!/usr/bin/env node\n// @elydora-cline-hook invalid\n"
fixture := prepareClineFixture(t, clineFixtureOptions{ExistingGuard: &corrupt})
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "parse Elydora Cline hook metadata") {
t.Fatalf("install error = %v", err)
}
if readClineTestFile(t, fixture.guardWrapper) != corrupt {
t.Fatalf("corrupt hook changed")
}
requireMissingClineTestFile(t, fixture.auditWrapper)
requireMissingClineTestFile(t, fixture.hookPath)
}
func TestClineInstallRejectsMissingGuardBeforeWrites(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{SkipGuard: 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.guardWrapper,
fixture.auditWrapper,
fixture.hookPath,
fixture.runtimeConfig,
fixture.privateKey,
} {
requireMissingClineTestFile(t, path)
}
}
func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
userHook := filepath.Join(fixture.hooksDir, "PreToolUse.py")
if err := os.WriteFile(userHook, []byte("# user hook\n"), 0600); err != nil {
t.Fatalf("write user hook: %v", err)
}
if err := fixture.plugin.Uninstall("agent-10"); err != nil {
t.Fatalf("uninstall other agent: %v", err)
}
readClineTestFile(t, fixture.guardWrapper)
readClineTestFile(t, fixture.auditWrapper)
if err := fixture.plugin.Uninstall(clineTestAgentID); err != nil {
t.Fatalf("uninstall Cline hooks: %v", err)
}
requireMissingClineTestFile(t, fixture.guardWrapper)
requireMissingClineTestFile(t, fixture.auditWrapper)
if readClineTestFile(t, userHook) != "# user hook\n" {
t.Fatalf("user hook changed")
}
}
func TestClineHookPairRollsBackFirstCommitWhenSecondCommitFails(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
originalGuard := readClineTestFile(t, fixture.guardWrapper)
originalAudit := readClineTestFile(t, fixture.auditWrapper)
guardState, err := readClineHookFile(fixture.guardWrapper)
if err != nil {
t.Fatalf("read guard state: %v", err)
}
auditState, err := readClineHookFile(fixture.auditWrapper)
if err != nil {
t.Fatalf("read audit state: %v", err)
}
guardMetadata, err := buildClineMetadata("guard", "agent-2", fixture.guardPath)
if err != nil {
t.Fatalf("build replacement guard metadata: %v", err)
}
auditMetadata, err := buildClineMetadata("audit", "agent-2", fixture.hookPath)
if err != nil {
t.Fatalf("build replacement audit metadata: %v", err)
}
replacementGuard, err := buildClineWrapper(guardMetadata)
if err != nil {
t.Fatalf("build replacement guard: %v", err)
}
replacementAudit, err := buildClineWrapper(auditMetadata)
if err != nil {
t.Fatalf("build replacement audit: %v", err)
}
failureInjected := false
rename := func(source, destination string) error {
if !failureInjected && sameClineTestPath(destination, fixture.auditWrapper) {
failureInjected = true
return errors.New("simulated audit commit failure")
}
return os.Rename(source, destination)
}
err = writeClineHookPairWithRename(
clinePendingWrite{state: guardState, source: replacementGuard},
clinePendingWrite{state: auditState, source: replacementAudit},
rename,
)
if err == nil || !strings.Contains(err.Error(), "write Cline hook pair") {
t.Fatalf("write pair error = %v", err)
}
if !failureInjected {
t.Fatalf("audit commit failure was not injected")
}
if readClineTestFile(t, fixture.guardWrapper) != originalGuard ||
readClineTestFile(t, fixture.auditWrapper) != originalAudit {
t.Fatalf("hook pair was not restored")
}
entries, err := os.ReadDir(fixture.hooksDir)
if err != nil {
t.Fatalf("read hook directory: %v", err)
}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".tmp") {
t.Fatalf("temporary hook remains: %s", entry.Name())
}
}
}
func sameClineTestPath(left, right string) bool {
leftPath, leftErr := filepath.Abs(left)
rightPath, rightErr := filepath.Abs(right)
return leftErr == nil && rightErr == nil && strings.EqualFold(leftPath, rightPath)
}
+178
View File
@@ -0,0 +1,178 @@
package plugins
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestClineRegistryUsesNativeGlobalHookDirectory(t *testing.T) {
entry := SupportedAgents["cline"]
if entry.Name != "Cline" || entry.ConfigDir != "~/.cline/hooks" || entry.ConfigFile != "PreToolUse.mjs" {
t.Fatalf("Cline registry entry = %#v", entry)
}
if _, ok := NewPlugin("cline").(*ClinePlugin); !ok {
t.Fatalf("Cline plugin is not registered")
}
}
func TestClineInstallWritesOnlyNativeGlobalHooksAndIsIdempotent(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
originalGuard := readClineTestFile(t, fixture.guardWrapper)
originalAudit := readClineTestFile(t, fixture.auditWrapper)
installClineFixture(t, fixture)
if readClineTestFile(t, fixture.guardWrapper) != originalGuard {
t.Fatalf("guard wrapper changed during idempotent install")
}
if readClineTestFile(t, fixture.auditWrapper) != originalAudit {
t.Fatalf("audit wrapper changed during idempotent install")
}
if !strings.HasPrefix(originalGuard, "#!/usr/bin/env node\n// @elydora-cline-hook ") {
t.Fatalf("guard wrapper has unexpected header: %q", originalGuard)
}
entries, err := os.ReadDir(fixture.hooksDir)
if err != nil {
t.Fatalf("read Cline hook directory: %v", err)
}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".tmp") {
t.Fatalf("temporary hook file remains: %s", entry.Name())
}
}
requireMissingClineTestFile(t, filepath.Join(fixture.homeDir, "Documents", "Cline", "Hooks", "PreToolUse.mjs"))
requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".cline", "hooks", "PreToolUse.mjs"))
requireMissingClineTestFile(t, filepath.Join(fixture.workspaceDir, ".clinerules", "hooks", "PreToolUse.mjs"))
}
func TestClineInstallUsesOfficialDefaultWhenClineDirIsEmpty(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
t.Setenv("CLINE_DIR", "")
installClineFixture(t, fixture)
defaultHooks := filepath.Join(fixture.homeDir, ".cline", "hooks")
readClineTestFile(t, filepath.Join(defaultHooks, "PreToolUse.mjs"))
readClineTestFile(t, filepath.Join(defaultHooks, "PostToolUse.mjs"))
requireMissingClineTestFile(t, fixture.guardWrapper)
}
func TestClineWrappersTranslateFreezeAndForwardPayloadByteForByte(t *testing.T) {
fixture := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, fixture)
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
captureJSON, err := json.Marshal(capturePath)
if err != nil {
t.Fatalf("marshal capture path: %v", err)
}
captureScript := "const fs = require('node:fs'); const chunks = []; " +
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
"process.stdin.on('end', () => fs.writeFileSync(" + string(captureJSON) + ", JSON.stringify({" +
"cwd: process.cwd(), input: Buffer.concat(chunks).toString('utf-8')})));\n"
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
t.Fatalf("write capture hook: %v", err)
}
prePayload := []byte(`{"clineVersion":"3.0.46","hookName":"tool_call","taskId":"task-1","tool_call":{"id":"call-1","name":"read_file","input":{"path":"README.md"}}}`)
guard := runClineWrapper(t, fixture, fixture.guardWrapper, prePayload)
if guard.exitCode != 0 || !strings.Contains(guard.stderr, "Agent is frozen by Elydora") {
t.Fatalf("guard result = %#v", guard)
}
control := decodeClineControl(t, guard.stdout)
if control["cancel"] != true || control["errorMessage"] != "Agent is frozen by Elydora." {
t.Fatalf("guard control = %#v", control)
}
postPayload := []byte("{\n \"clineVersion\": \"3.0.46\",\n \"hookName\": \"tool_result\",\n \"taskId\": \"task-1\",\n \"tool_result\": {\"name\": \"read_file\", \"input\": {\"path\": \"README.md\"}}\n}")
audit := runClineWrapper(t, fixture, fixture.auditWrapper, postPayload)
if audit.exitCode != 0 || audit.stdout != "" {
t.Fatalf("audit result = %#v", audit)
}
var captured map[string]any
if err := json.Unmarshal([]byte(readClineTestFile(t, capturePath)), &captured); err != nil {
t.Fatalf("decode captured payload: %v", err)
}
if captured["cwd"] != fixture.workspaceDir || captured["input"] != string(postPayload) {
t.Fatalf("captured payload = %#v", captured)
}
}
func TestClineAuditRuntimeMapsOfficialNestedFields(t *testing.T) {
operations := make(chan map[string]any, 1)
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
defer request.Body.Close()
var operation map[string]any
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
http.Error(response, err.Error(), http.StatusBadRequest)
return
}
operations <- operation
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusCreated)
_, _ = response.Write([]byte(`{}`))
}))
defer server.Close()
fixture := prepareClineFixture(t, clineFixtureOptions{})
fixture.config.BaseURL = server.URL
installClineFixture(t, fixture)
payload := []byte(`{"hookName":"tool_result","taskId":"task-1","tool_result":{"name":"read_file","input":{"path":"README.md"},"output":"ok"}}`)
result := runClineWrapper(t, fixture, fixture.auditWrapper, payload)
if result.exitCode != 0 {
t.Fatalf("audit wrapper result = %#v", result)
}
var operation map[string]any
select {
case operation = <-operations:
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for Elydora operation")
}
wantPayload := map[string]any{
"tool_name": "read_file",
"tool_input": map[string]any{"path": "README.md"},
"session_id": "task-1",
}
if !reflect.DeepEqual(requireClineTestObject(t, operation["payload"]), wantPayload) {
t.Fatalf("operation payload = %#v", operation["payload"])
}
if !reflect.DeepEqual(requireClineTestObject(t, operation["action"]), map[string]any{"tool": "read_file"}) {
t.Fatalf("operation action = %#v", operation["action"])
}
if !reflect.DeepEqual(requireClineTestObject(t, operation["subject"]), map[string]any{"session_id": "task-1"}) {
t.Fatalf("operation subject = %#v", operation["subject"])
}
}
func TestClineWrappersKeepPassesQuietAndSurfaceRuntimeFailures(t *testing.T) {
passing := prepareClineFixture(t, clineFixtureOptions{
GuardSource: clineString("process.stdin.resume();\n"),
})
installClineFixture(t, passing)
passResult := runClineWrapper(t, passing, passing.guardWrapper, []byte(`{}`))
if passResult.exitCode != 0 || passResult.stdout != "" {
t.Fatalf("passing guard result = %#v", passResult)
}
failing := prepareClineFixture(t, clineFixtureOptions{})
installClineFixture(t, failing)
if err := os.WriteFile(
failing.hookPath,
[]byte("process.stderr.write('audit failed\\n'); process.exit(7);\n"),
0700,
); err != nil {
t.Fatalf("write failing audit runtime: %v", err)
}
failResult := runClineWrapper(t, failing, failing.auditWrapper, []byte(`{}`))
if failResult.exitCode != 1 || !strings.Contains(failResult.stderr, "audit failed") ||
!strings.Contains(failResult.stderr, "exited with code 7") {
t.Fatalf("failing audit result = %#v", failResult)
}
}
@@ -0,0 +1,205 @@
package plugins
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
const clineTestAgentID = "agent-1"
type clineFixtureOptions struct {
ExistingAudit *string
ExistingGuard *string
GuardSource *string
SkipGuard bool
}
type clineFixture struct {
plugin *ClinePlugin
config InstallConfig
homeDir string
workspaceDir string
clineDir string
hooksDir string
agentDir string
guardPath string
hookPath string
guardWrapper string
auditWrapper string
runtimeConfig string
privateKey string
}
type clineCommandResult struct {
exitCode int
stdout string
stderr string
}
func clineString(value string) *string {
return &value
}
func prepareClineFixture(t *testing.T, options clineFixtureOptions) *clineFixture {
t.Helper()
rootDir := t.TempDir()
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
workspaceDir := filepath.Join(rootDir, "workspace")
clineDir := filepath.Join(rootDir, "custom-cline-home")
hooksDir := filepath.Join(clineDir, "hooks")
agentDir := filepath.Join(homeDir, ".elydora", clineTestAgentID)
guardPath := filepath.Join(agentDir, "guard.js")
hookPath := filepath.Join(agentDir, "hook.js")
guardWrapper := filepath.Join(hooksDir, "PreToolUse.mjs")
auditWrapper := filepath.Join(hooksDir, "PostToolUse.mjs")
for _, directory := range []string{workspaceDir, agentDir} {
if err := os.MkdirAll(directory, 0755); err != nil {
t.Fatalf("create fixture directory %s: %v", directory, err)
}
}
if !options.SkipGuard {
guardSource := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.\\n'); process.exit(2);\n"
if options.GuardSource != nil {
guardSource = *options.GuardSource
}
if err := os.WriteFile(guardPath, []byte(guardSource), 0700); err != nil {
t.Fatalf("write guard runtime: %v", err)
}
}
writeOptionalClineTestFile(t, guardWrapper, options.ExistingGuard)
writeOptionalClineTestFile(t, auditWrapper, options.ExistingAudit)
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
t.Setenv("CLINE_DIR", clineDir)
return &clineFixture{
plugin: &ClinePlugin{},
homeDir: homeDir,
workspaceDir: workspaceDir,
clineDir: clineDir,
hooksDir: hooksDir,
agentDir: agentDir,
guardPath: guardPath,
hookPath: hookPath,
guardWrapper: guardWrapper,
auditWrapper: auditWrapper,
runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"),
config: InstallConfig{
AgentName: "cline",
OrgID: "org-1",
AgentID: clineTestAgentID,
PrivateKey: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE",
KID: "kid-1",
BaseURL: "https://api.elydora.test",
GuardScriptPath: guardPath,
},
}
}
func writeOptionalClineTestFile(t *testing.T, path string, contents *string) {
t.Helper()
if contents == nil {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("create directory for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(*contents), 0600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func installClineFixture(t *testing.T, fixture *clineFixture) {
t.Helper()
if err := fixture.plugin.Install(fixture.config); err != nil {
t.Fatalf("install Cline hooks: %v", err)
}
}
func runClineWrapper(
t *testing.T,
fixture *clineFixture,
wrapperPath string,
payload []byte,
) clineCommandResult {
t.Helper()
nodePath, err := exec.LookPath("node")
if err != nil {
t.Fatalf("resolve Node.js runtime: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// #nosec G204 -- nodePath is resolved with exec.LookPath and the wrapper path is test-owned.
command := exec.CommandContext(ctx, nodePath, wrapperPath)
command.Dir = fixture.workspaceDir
command.Env = append(
os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
)
command.Stdin = bytes.NewReader(payload)
var stdout bytes.Buffer
var stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
runErr := command.Run()
if ctx.Err() != nil {
t.Fatalf("Cline wrapper timed out: %v", ctx.Err())
}
exitCode := 0
if runErr != nil {
var exitError *exec.ExitError
if !errors.As(runErr, &exitError) {
t.Fatalf("run Cline wrapper: %v", runErr)
}
exitCode = exitError.ExitCode()
}
return clineCommandResult{exitCode: exitCode, stdout: stdout.String(), stderr: stderr.String()}
}
func readClineTestFile(t *testing.T, path string) string {
t.Helper()
contents, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(contents)
}
func requireMissingClineTestFile(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected %s to be missing, got %v", path, err)
}
}
func requireClineTestObject(t *testing.T, value any) map[string]any {
t.Helper()
object, ok := value.(map[string]any)
if !ok {
t.Fatalf("value is not an object: %#v", value)
}
return object
}
func decodeClineControl(t *testing.T, stdout string) map[string]any {
t.Helper()
const marker = "HOOK_CONTROL\t"
if len(stdout) < len(marker) || stdout[:len(marker)] != marker {
t.Fatalf("Cline control output = %q", stdout)
}
var control map[string]any
if err := json.Unmarshal([]byte(stdout[len(marker):]), &control); err != nil {
t.Fatalf("decode Cline control output: %v", err)
}
return control
}
+14 -5
View File
@@ -354,11 +354,20 @@ async function main() {
return;
}
// Extract tool_name and tool_input from hook data
// Different agents may pass data in different shapes
const toolName = hookData.tool_name || hookData.toolName || hookData.name || 'unknown';
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || {};
const sessionId = hookData.session_id || hookData.sessionId || hookData.session || 'unknown';
// Extract provider-native tool fields without reshaping the hook input.
const clineToolEvent =
hookData.tool_result && typeof hookData.tool_result === 'object'
? hookData.tool_result
: hookData.tool_call && typeof hookData.tool_call === 'object'
? hookData.tool_call
: {};
const toolName =
hookData.tool_name || hookData.toolName || hookData.name || clineToolEvent.name || 'unknown';
const toolInput =
hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters ||
clineToolEvent.input || {};
const sessionId =
hookData.session_id || hookData.sessionId || hookData.session || hookData.taskId || 'unknown';
// Read agent config and private key
let config;
+3
View File
@@ -12,6 +12,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
"augment": {Name: "Augment Code CLI", ConfigDir: "~/.augment", ConfigFile: "settings.json"},
"claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"},
"codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.json"},
"cline": {Name: "Cline", ConfigDir: "~/.cline/hooks", ConfigFile: "PreToolUse.mjs"},
"cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"},
"grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"},
@@ -33,6 +34,8 @@ func NewPlugin(agentName string) AgentPlugin {
return &ClaudeCodePlugin{}
case "codex":
return &CodexPlugin{}
case "cline":
return &ClinePlugin{}
case "cursor":
return &CursorPlugin{}
case "gemini":