344 lines
9.8 KiB
Go
344 lines
9.8 KiB
Go
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 {
|
|
clineDirectory string
|
|
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 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{
|
|
clineDirectory: clineDirectory,
|
|
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(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 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")
|
|
}
|
|
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Elydora Cline hook metadata contains an invalid agentId: %w", err)
|
|
}
|
|
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
|
|
}
|